From cd09666f1db2bba77446a7d3e84ea3170def7558 Mon Sep 17 00:00:00 2001
From: FrankZamora
Date: Thu, 27 Nov 2025 14:31:04 -0600
Subject: [PATCH] Backup antes de optimizar Bootstrap Icons (subset)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Estado actual:
- Bootstrap Icons completo: 211 KB (2050 iconos)
- Solo usamos 105 iconos (5.1%)
Próximo paso: crear subset de iconos para ahorrar ~199 KB
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.../AdsensePlacementFieldMapper.php | 70 ++++
.../Ui/AdsensePlacementFormBuilder.php | 381 ++++++++++++++++++
.../Ui/AdminDashboardRenderer.php | 5 +
.../FieldMapping/FieldMapperProvider.php | 1 +
Inc/adsense-placement.php | 196 +++++++++
.../Services/ContentAdInjector.php | 73 ++++
.../Ui/AdsensePlacementRenderer.php | 356 ++++++++++++++++
Shared/Infrastructure/Di/DIContainer.php | 20 +
functions.php | 1 +
9 files changed, 1103 insertions(+)
create mode 100644 Admin/AdsensePlacement/Infrastructure/FieldMapping/AdsensePlacementFieldMapper.php
create mode 100644 Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php
create mode 100644 Inc/adsense-placement.php
create mode 100644 Public/AdsensePlacement/Infrastructure/Services/ContentAdInjector.php
create mode 100644 Public/AdsensePlacement/Infrastructure/Ui/AdsensePlacementRenderer.php
diff --git a/Admin/AdsensePlacement/Infrastructure/FieldMapping/AdsensePlacementFieldMapper.php b/Admin/AdsensePlacement/Infrastructure/FieldMapping/AdsensePlacementFieldMapper.php
new file mode 100644
index 00000000..5de53bf2
--- /dev/null
+++ b/Admin/AdsensePlacement/Infrastructure/FieldMapping/AdsensePlacementFieldMapper.php
@@ -0,0 +1,70 @@
+ ['group' => 'visibility', 'attribute' => 'is_enabled'],
+ 'adsense-placementDisableAutoAds' => ['group' => 'visibility', 'attribute' => 'disable_auto_ads'],
+ 'adsense-placementShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
+ 'adsense-placementShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
+
+ // CONTENT (Credentials)
+ 'adsense-placementPublisherId' => ['group' => 'content', 'attribute' => 'publisher_id'],
+ 'adsense-placementSlotDisplay' => ['group' => 'content', 'attribute' => 'slot_display'],
+ 'adsense-placementSlotAuto' => ['group' => 'content', 'attribute' => 'slot_auto'],
+ 'adsense-placementSlotAutorelaxed' => ['group' => 'content', 'attribute' => 'slot_autorelaxed'],
+ 'adsense-placementSlotInarticle' => ['group' => 'content', 'attribute' => 'slot_inarticle'],
+ 'adsense-placementSlotSkyscraper' => ['group' => 'content', 'attribute' => 'slot_skyscraper'],
+
+ // BEHAVIOR (Post locations + formats)
+ 'adsense-placementPostTopEnabled' => ['group' => 'behavior', 'attribute' => 'post_top_enabled'],
+ 'adsense-placementPostTopFormat' => ['group' => 'behavior', 'attribute' => 'post_top_format'],
+ 'adsense-placementPostContentEnabled' => ['group' => 'behavior', 'attribute' => 'post_content_enabled'],
+ 'adsense-placementPostContentAfterParagraphs' => ['group' => 'behavior', 'attribute' => 'post_content_after_paragraphs'],
+ 'adsense-placementPostContentMaxAds' => ['group' => 'behavior', 'attribute' => 'post_content_max_ads'],
+ 'adsense-placementPostContentFormat' => ['group' => 'behavior', 'attribute' => 'post_content_format'],
+ 'adsense-placementPostBottomEnabled' => ['group' => 'behavior', 'attribute' => 'post_bottom_enabled'],
+ 'adsense-placementPostBottomFormat' => ['group' => 'behavior', 'attribute' => 'post_bottom_format'],
+ 'adsense-placementAfterRelatedEnabled' => ['group' => 'behavior', 'attribute' => 'after_related_enabled'],
+ 'adsense-placementAfterRelatedFormat' => ['group' => 'behavior', 'attribute' => 'after_related_format'],
+
+ // BEHAVIOR (Rail Ads)
+ 'adsense-placementRailAdsEnabled' => ['group' => 'behavior', 'attribute' => 'rail_ads_enabled'],
+ 'adsense-placementRailLeftEnabled' => ['group' => 'behavior', 'attribute' => 'rail_left_enabled'],
+ 'adsense-placementRailRightEnabled' => ['group' => 'behavior', 'attribute' => 'rail_right_enabled'],
+ 'adsense-placementRailFormat' => ['group' => 'behavior', 'attribute' => 'rail_format'],
+ 'adsense-placementRailTopOffset' => ['group' => 'behavior', 'attribute' => 'rail_top_offset'],
+
+ // LAYOUT (Archive/Global locations + formats)
+ 'adsense-placementArchiveTopEnabled' => ['group' => 'layout', 'attribute' => 'archive_top_enabled'],
+ 'adsense-placementArchiveBetweenEnabled' => ['group' => 'layout', 'attribute' => 'archive_between_enabled'],
+ 'adsense-placementArchiveBetweenEvery' => ['group' => 'layout', 'attribute' => 'archive_between_every'],
+ 'adsense-placementArchiveBottomEnabled' => ['group' => 'layout', 'attribute' => 'archive_bottom_enabled'],
+ 'adsense-placementArchiveFormat' => ['group' => 'layout', 'attribute' => 'archive_format'],
+ 'adsense-placementHeaderBelowEnabled' => ['group' => 'layout', 'attribute' => 'header_below_enabled'],
+ 'adsense-placementFooterAboveEnabled' => ['group' => 'layout', 'attribute' => 'footer_above_enabled'],
+ 'adsense-placementGlobalFormat' => ['group' => 'layout', 'attribute' => 'global_format'],
+
+ // FORMS (Exclusions + Delay)
+ 'adsense-placementExcludeCategories' => ['group' => 'forms', 'attribute' => 'exclude_categories'],
+ 'adsense-placementExcludePostTypes' => ['group' => 'forms', 'attribute' => 'exclude_post_types'],
+ 'adsense-placementExcludePostIds' => ['group' => 'forms', 'attribute' => 'exclude_post_ids'],
+ 'adsense-placementMinContentLength' => ['group' => 'forms', 'attribute' => 'min_content_length'],
+ 'adsense-placementDelayEnabled' => ['group' => 'forms', 'attribute' => 'delay_enabled'],
+ 'adsense-placementDelayTimeout' => ['group' => 'forms', 'attribute' => 'delay_timeout'],
+ ];
+ }
+}
diff --git a/Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php b/Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php
new file mode 100644
index 00000000..1a13cf6f
--- /dev/null
+++ b/Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php
@@ -0,0 +1,381 @@
+';
+ $html .= ' ';
+ $html .= '
';
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' Control de Anuncios AdSense';
+ $html .= '
';
+ $html .= '
';
+ $html .= ' Configura ubicaciones manuales de anuncios para evitar Auto Ads';
+ $html .= '
';
+ $html .= '
';
+ $html .= '
';
+ $html .= '';
+
+ // LAYOUT 2 COLUMNAS
+ $html .= '';
+
+ // COLUMNA IZQUIERDA
+ $html .= '
';
+ $html .= $this->buildVisibilityGroup($componentId);
+ $html .= $this->buildCredentialsGroup($componentId);
+ $html .= $this->buildPostLocationsGroup($componentId);
+ $html .= '
';
+
+ // COLUMNA DERECHA
+ $html .= '
';
+ $html .= $this->buildRailAdsGroup($componentId);
+ $html .= $this->buildArchiveLocationsGroup($componentId);
+ $html .= $this->buildExclusionsGroup($componentId);
+ $html .= '
';
+
+ $html .= '
';
+
+ return $html;
+ }
+
+ private function buildVisibilityGroup(string $cid): string
+ {
+ $html = '';
+ $html .= '
';
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' Activacion y Visibilidad';
+ $html .= '
';
+
+ // Switch: Enabled
+ $enabled = $this->renderer->getFieldValue($cid, 'visibility', 'is_enabled', false);
+ $html .= $this->buildSwitch($cid . 'Enabled', 'Activar Placement Manual', $enabled, 'bi-power');
+
+ // Switch: Disable Auto Ads
+ $disableAuto = $this->renderer->getFieldValue($cid, 'visibility', 'disable_auto_ads', true);
+ $html .= $this->buildSwitch($cid . 'DisableAutoAds', 'Deshabilitar Auto Ads de Google', $disableAuto, 'bi-shield-x');
+
+ // Switch: Show on Mobile
+ $showMobile = $this->renderer->getFieldValue($cid, 'visibility', 'show_on_mobile', true);
+ $html .= $this->buildSwitch($cid . 'ShowOnMobile', 'Mostrar en movil', $showMobile, 'bi-phone');
+
+ // Switch: Show on Desktop
+ $showDesktop = $this->renderer->getFieldValue($cid, 'visibility', 'show_on_desktop', true);
+ $html .= $this->buildSwitch($cid . 'ShowOnDesktop', 'Mostrar en escritorio', $showDesktop, 'bi-display');
+
+ $html .= ' ';
+ $html .= '
';
+
+ return $html;
+ }
+
+ private function buildCredentialsGroup(string $cid): string
+ {
+ $html = '';
+ $html .= '
';
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' Credenciales AdSense';
+ $html .= '
';
+
+ // Publisher ID
+ $pubId = $this->renderer->getFieldValue($cid, 'content', 'publisher_id', 'ca-pub-8476420265998726');
+ $html .= $this->buildTextInput($cid . 'PublisherId', 'Publisher ID', $pubId, 'ca-pub-XXXXX');
+
+ // Slots (grid 2 columnas)
+ $html .= '
';
+ $html .= '
';
+ $slotDisplay = $this->renderer->getFieldValue($cid, 'content', 'slot_display', '2873062302');
+ $html .= $this->buildTextInput($cid . 'SlotDisplay', 'Slot Display', $slotDisplay);
+ $html .= '
';
+ $html .= '
';
+ $slotAuto = $this->renderer->getFieldValue($cid, 'content', 'slot_auto', '8471732096');
+ $html .= $this->buildTextInput($cid . 'SlotAuto', 'Slot Auto', $slotAuto);
+ $html .= '
';
+ $html .= '
';
+ $slotRelaxed = $this->renderer->getFieldValue($cid, 'content', 'slot_autorelaxed', '9205569855');
+ $html .= $this->buildTextInput($cid . 'SlotAutorelaxed', 'Slot Autorelaxed', $slotRelaxed);
+ $html .= '
';
+ $html .= '
';
+ $slotInArticle = $this->renderer->getFieldValue($cid, 'content', 'slot_inarticle', '7285187368');
+ $html .= $this->buildTextInput($cid . 'SlotInarticle', 'Slot In-Article', $slotInArticle);
+ $html .= '
';
+ $html .= '
';
+ $slotSkyscraper = $this->renderer->getFieldValue($cid, 'content', 'slot_skyscraper', '');
+ $html .= $this->buildTextInput($cid . 'SlotSkyscraper', 'Slot Skyscraper (Rail Ads)', $slotSkyscraper);
+ $html .= '
';
+ $html .= '
';
+
+ $html .= '
';
+ $html .= '
';
+
+ return $html;
+ }
+
+ private function buildPostLocationsGroup(string $cid): string
+ {
+ $html = '';
+ $html .= '
';
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' Ubicaciones en Posts';
+ $html .= '
';
+
+ // Post Top
+ $postTopEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'post_top_enabled', true);
+ $html .= $this->buildSwitch($cid . 'PostTopEnabled', 'Despues de Featured Image', $postTopEnabled);
+ $html .= $this->buildSelect($cid . 'PostTopFormat', 'Formato',
+ $this->renderer->getFieldValue($cid, 'behavior', 'post_top_format', 'auto'),
+ ['auto' => 'Auto (responsive)', 'in-article' => 'In-Article', 'display' => 'Display (728x90)', 'display-large' => 'Display Large (970x250)']
+ );
+
+ // Post Content
+ $postContentEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'post_content_enabled', false);
+ $html .= $this->buildSwitch($cid . 'PostContentEnabled', 'Insertar dentro del contenido', $postContentEnabled);
+
+ $html .= '
';
+ $html .= '
';
+ $afterPara = $this->renderer->getFieldValue($cid, 'behavior', 'post_content_after_paragraphs', '3');
+ $html .= $this->buildTextInput($cid . 'PostContentAfterParagraphs', 'Despues parrafo #', $afterPara);
+ $html .= '
';
+ $html .= '
';
+ $maxAds = $this->renderer->getFieldValue($cid, 'behavior', 'post_content_max_ads', '2');
+ $html .= $this->buildTextInput($cid . 'PostContentMaxAds', 'Max ads', $maxAds);
+ $html .= '
';
+ $html .= '
';
+ $html .= $this->buildSelect($cid . 'PostContentFormat', 'Formato',
+ $this->renderer->getFieldValue($cid, 'behavior', 'post_content_format', 'in-article'),
+ ['in-article' => 'In-Article', 'auto' => 'Auto']
+ );
+ $html .= '
';
+ $html .= '
';
+
+ // Post Bottom
+ $postBottomEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'post_bottom_enabled', true);
+ $html .= $this->buildSwitch($cid . 'PostBottomEnabled', 'Despues del contenido', $postBottomEnabled);
+ $html .= $this->buildSelect($cid . 'PostBottomFormat', 'Formato',
+ $this->renderer->getFieldValue($cid, 'behavior', 'post_bottom_format', 'auto'),
+ ['auto' => 'Auto', 'in-article' => 'In-Article', 'display' => 'Display']
+ );
+
+ // After Related
+ $afterRelatedEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'after_related_enabled', false);
+ $html .= $this->buildSwitch($cid . 'AfterRelatedEnabled', 'Despues de Related Posts', $afterRelatedEnabled);
+ $html .= $this->buildSelect($cid . 'AfterRelatedFormat', 'Formato',
+ $this->renderer->getFieldValue($cid, 'behavior', 'after_related_format', 'autorelaxed'),
+ ['autorelaxed' => 'Autorelaxed', 'auto' => 'Auto']
+ );
+
+ $html .= '
';
+ $html .= '
';
+
+ return $html;
+ }
+
+ private function buildRailAdsGroup(string $cid): string
+ {
+ $html = '';
+ $html .= '
';
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' Rail Ads (Margenes Laterales)';
+ $html .= '
';
+ $html .= '
Anuncios fijos en los espacios laterales del viewport. Solo visibles en pantallas >= 1600px.
';
+
+ // Master switch
+ $railEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'rail_ads_enabled', false);
+ $html .= $this->buildSwitch($cid . 'RailAdsEnabled', 'Activar Rail Ads', $railEnabled, 'bi-power');
+
+ // Left/Right toggles
+ $html .= '
';
+ $html .= '
';
+ $leftEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'rail_left_enabled', true);
+ $html .= $this->buildSwitch($cid . 'RailLeftEnabled', 'Rail izquierdo', $leftEnabled);
+ $html .= '
';
+ $html .= '
';
+ $rightEnabled = $this->renderer->getFieldValue($cid, 'behavior', 'rail_right_enabled', true);
+ $html .= $this->buildSwitch($cid . 'RailRightEnabled', 'Rail derecho', $rightEnabled);
+ $html .= '
';
+ $html .= '
';
+
+ // Format select
+ $railFormat = $this->renderer->getFieldValue($cid, 'behavior', 'rail_format', 'skyscraper');
+ $html .= $this->buildSelect($cid . 'RailFormat', 'Formato',
+ $railFormat,
+ ['skyscraper' => 'Skyscraper (160x600)', 'half-page' => 'Half Page (300x600)']
+ );
+
+ // Top offset
+ $topOffset = $this->renderer->getFieldValue($cid, 'behavior', 'rail_top_offset', '150');
+ $html .= $this->buildTextInput($cid . 'RailTopOffset', 'Distancia desde arriba (px)', $topOffset);
+
+ $html .= '
';
+ $html .= '
';
+
+ return $html;
+ }
+
+ private function buildArchiveLocationsGroup(string $cid): string
+ {
+ $html = '';
+ $html .= '
';
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' Ubicaciones Archives/Globales';
+ $html .= '
';
+
+ // Archive locations
+ $archiveTopEnabled = $this->renderer->getFieldValue($cid, 'layout', 'archive_top_enabled', false);
+ $html .= $this->buildSwitch($cid . 'ArchiveTopEnabled', 'Arriba del listado', $archiveTopEnabled);
+
+ $archiveBetweenEnabled = $this->renderer->getFieldValue($cid, 'layout', 'archive_between_enabled', false);
+ $html .= $this->buildSwitch($cid . 'ArchiveBetweenEnabled', 'Entre posts del listado', $archiveBetweenEnabled);
+
+ $archiveEvery = $this->renderer->getFieldValue($cid, 'layout', 'archive_between_every', '4');
+ $html .= $this->buildTextInput($cid . 'ArchiveBetweenEvery', 'Mostrar cada X posts', $archiveEvery);
+
+ $archiveBottomEnabled = $this->renderer->getFieldValue($cid, 'layout', 'archive_bottom_enabled', false);
+ $html .= $this->buildSwitch($cid . 'ArchiveBottomEnabled', 'Abajo del listado', $archiveBottomEnabled);
+
+ // Archive format (aplica a todas las ubicaciones archive)
+ $html .= $this->buildSelect($cid . 'ArchiveFormat', 'Formato para archives',
+ $this->renderer->getFieldValue($cid, 'layout', 'archive_format', 'autorelaxed'),
+ ['autorelaxed' => 'Autorelaxed', 'auto' => 'Auto']
+ );
+
+ $html .= '
';
+ $html .= '
Ubicaciones Globales
';
+
+ // Global locations
+ $headerBelowEnabled = $this->renderer->getFieldValue($cid, 'layout', 'header_below_enabled', false);
+ $html .= $this->buildSwitch($cid . 'HeaderBelowEnabled', 'Debajo del header (global)', $headerBelowEnabled);
+
+ $footerAboveEnabled = $this->renderer->getFieldValue($cid, 'layout', 'footer_above_enabled', false);
+ $html .= $this->buildSwitch($cid . 'FooterAboveEnabled', 'Arriba del footer (global)', $footerAboveEnabled);
+
+ // Global format
+ $html .= $this->buildSelect($cid . 'GlobalFormat', 'Formato para globales',
+ $this->renderer->getFieldValue($cid, 'layout', 'global_format', 'auto'),
+ ['auto' => 'Auto', 'display-large' => 'Display Large (970x250)']
+ );
+
+ $html .= '
';
+ $html .= '
';
+
+ return $html;
+ }
+
+ private function buildExclusionsGroup(string $cid): string
+ {
+ $html = '';
+ $html .= '
';
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' Exclusiones y Rendimiento';
+ $html .= '
';
+
+ // Exclusions
+ $excludeCats = $this->renderer->getFieldValue($cid, 'forms', 'exclude_categories', '');
+ $html .= $this->buildTextarea($cid . 'ExcludeCategories', 'Excluir categorias (IDs)', $excludeCats, 'Ej: 5,12,23');
+
+ $excludeTypes = $this->renderer->getFieldValue($cid, 'forms', 'exclude_post_types', '');
+ $html .= $this->buildTextarea($cid . 'ExcludePostTypes', 'Excluir tipos de post', $excludeTypes, 'Ej: page,attachment');
+
+ $excludeIds = $this->renderer->getFieldValue($cid, 'forms', 'exclude_post_ids', '');
+ $html .= $this->buildTextarea($cid . 'ExcludePostIds', 'Excluir posts (IDs)', $excludeIds, 'Ej: 100,205,310');
+
+ $minLength = $this->renderer->getFieldValue($cid, 'forms', 'min_content_length', '500');
+ $html .= $this->buildTextInput($cid . 'MinContentLength', 'Longitud minima de contenido', $minLength);
+
+ // Delay settings
+ $delayEnabled = $this->renderer->getFieldValue($cid, 'forms', 'delay_enabled', true);
+ $html .= $this->buildSwitch($cid . 'DelayEnabled', 'Retrasar carga de anuncios', $delayEnabled, 'bi-hourglass-split');
+
+ $delayTimeout = $this->renderer->getFieldValue($cid, 'forms', 'delay_timeout', '5000');
+ $html .= $this->buildTextInput($cid . 'DelayTimeout', 'Timeout de delay (ms)', $delayTimeout);
+
+ $html .= ' ';
+ $html .= '
';
+
+ return $html;
+ }
+
+ // === HELPERS ===
+
+ private function buildSwitch(string $id, string $label, $value, string $icon = ''): string
+ {
+ $checked = checked($value, true, false);
+ $iconHtml = $icon ? '' : '';
+
+ return sprintf(
+ '',
+ esc_attr($id), $checked, esc_attr($id), $iconHtml, esc_html($label)
+ );
+ }
+
+ private function buildTextInput(string $id, string $label, string $value, string $placeholder = ''): string
+ {
+ return sprintf(
+ '
+
+
+
',
+ esc_attr($id), esc_html($label), esc_attr($id), esc_attr($value), esc_attr($placeholder)
+ );
+ }
+
+ private function buildTextarea(string $id, string $label, string $value, string $placeholder = ''): string
+ {
+ return sprintf(
+ '
+
+
+
',
+ esc_attr($id), esc_html($label), esc_attr($id), esc_attr($placeholder), esc_textarea($value)
+ );
+ }
+
+ private function buildSelect(string $id, string $label, string $value, array $options): string
+ {
+ $optionsHtml = '';
+ foreach ($options as $optValue => $optLabel) {
+ $selected = selected($value, $optValue, false);
+ $optionsHtml .= sprintf(
+ '',
+ esc_attr($optValue),
+ $selected,
+ esc_html($optLabel)
+ );
+ }
+
+ return sprintf(
+ '
+
+
+
',
+ esc_attr($id), esc_html($label), esc_attr($id), $optionsHtml
+ );
+ }
+}
diff --git a/Admin/Infrastructure/Ui/AdminDashboardRenderer.php b/Admin/Infrastructure/Ui/AdminDashboardRenderer.php
index 27fddea7..905f5725 100644
--- a/Admin/Infrastructure/Ui/AdminDashboardRenderer.php
+++ b/Admin/Infrastructure/Ui/AdminDashboardRenderer.php
@@ -111,6 +111,11 @@ final class AdminDashboardRenderer implements DashboardRendererInterface
'label' => 'Theme Settings',
'icon' => 'bi-gear',
],
+ 'adsense-placement' => [
+ 'id' => 'adsense-placement',
+ 'label' => 'AdSense',
+ 'icon' => 'bi-megaphone',
+ ],
];
}
diff --git a/Admin/Shared/Infrastructure/FieldMapping/FieldMapperProvider.php b/Admin/Shared/Infrastructure/FieldMapping/FieldMapperProvider.php
index d32c3e90..acdc7790 100644
--- a/Admin/Shared/Infrastructure/FieldMapping/FieldMapperProvider.php
+++ b/Admin/Shared/Infrastructure/FieldMapping/FieldMapperProvider.php
@@ -32,6 +32,7 @@ final class FieldMapperProvider
'ContactForm',
'Footer',
'ThemeSettings',
+ 'AdsensePlacement',
];
public function __construct(
diff --git a/Inc/adsense-placement.php b/Inc/adsense-placement.php
new file mode 100644
index 00000000..970eb023
--- /dev/null
+++ b/Inc/adsense-placement.php
@@ -0,0 +1,196 @@
+getComponentSettingsRepository();
+ $settings = $repository->getComponentSettings('adsense-placement');
+
+ if (empty($settings)) {
+ return '';
+ }
+
+ // Verificar exclusiones
+ if (roi_is_ad_excluded($settings)) {
+ return '';
+ }
+
+ // Obtener renderer desde DIContainer (DIP compliant)
+ $renderer = $container->getAdsensePlacementRenderer();
+
+ return $renderer->renderSlot($settings, $location);
+
+ } catch (\Throwable $e) {
+ if (defined('WP_DEBUG') && WP_DEBUG) {
+ error_log('ROI AdSense: ' . $e->getMessage());
+ }
+ return '';
+ }
+}
+
+/**
+ * Verifica si el contenido actual esta excluido
+ */
+function roi_is_ad_excluded(array $settings): bool
+{
+ $forms = $settings['forms'] ?? [];
+
+ // Excluir categorias
+ $excludeCats = array_filter(array_map('trim', explode(',', $forms['exclude_categories'] ?? '')));
+ if (!empty($excludeCats) && is_single()) {
+ $postCats = wp_get_post_categories(get_the_ID());
+ if (array_intersect($excludeCats, $postCats)) {
+ return true;
+ }
+ }
+
+ // Excluir tipos de post
+ $excludeTypes = array_filter(array_map('trim', explode(',', $forms['exclude_post_types'] ?? '')));
+ if (!empty($excludeTypes) && in_array(get_post_type(), $excludeTypes, true)) {
+ return true;
+ }
+
+ // Excluir posts especificos
+ $excludeIds = array_filter(array_map('trim', explode(',', $forms['exclude_post_ids'] ?? '')));
+ if (!empty($excludeIds) && in_array((string)get_the_ID(), $excludeIds, true)) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Renderiza los Rail Ads (margenes laterales del viewport)
+ * Se llama desde wp_footer para inyectar al final del body
+ *
+ * NOTA DIP: El renderer se obtiene del DIContainer, NO se instancia directamente.
+ */
+function roi_render_rail_ads(): string
+{
+ global $container;
+
+ if ($container === null) {
+ return '';
+ }
+
+ try {
+ $repository = $container->getComponentSettingsRepository();
+ $settings = $repository->getComponentSettings('adsense-placement');
+
+ if (empty($settings)) {
+ return '';
+ }
+
+ // Verificar exclusiones
+ if (roi_is_ad_excluded($settings)) {
+ return '';
+ }
+
+ // Obtener renderer desde DIContainer (DIP compliant)
+ $renderer = $container->getAdsensePlacementRenderer();
+
+ return $renderer->renderRailAds($settings);
+
+ } catch (\Throwable $e) {
+ if (defined('WP_DEBUG') && WP_DEBUG) {
+ error_log('ROI AdSense Rail Ads: ' . $e->getMessage());
+ }
+ return '';
+ }
+}
+
+/**
+ * Hook para inyectar Rail Ads en el footer
+ */
+add_action('wp_footer', function() {
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo roi_render_rail_ads();
+}, 50);
+
+/**
+ * Verifica si se debe deshabilitar Auto Ads
+ */
+function roi_should_disable_auto_ads(): bool
+{
+ global $container;
+
+ if ($container === null) {
+ return false;
+ }
+
+ try {
+ $repository = $container->getComponentSettingsRepository();
+ $settings = $repository->getComponentSettings('adsense-placement');
+
+ $isEnabled = ($settings['visibility']['is_enabled'] ?? false) === true;
+ $disableAutoAds = ($settings['visibility']['disable_auto_ads'] ?? true) === true;
+
+ return $isEnabled && $disableAutoAds;
+ } catch (\Throwable $e) {
+ return false;
+ }
+}
+
+/**
+ * Carga el script principal de AdSense
+ */
+function roi_enqueue_adsense_script(): void
+{
+ global $container;
+
+ if ($container === null || is_admin()) {
+ return;
+ }
+
+ try {
+ $repository = $container->getComponentSettingsRepository();
+ $settings = $repository->getComponentSettings('adsense-placement');
+
+ if (!($settings['visibility']['is_enabled'] ?? false)) {
+ return;
+ }
+
+ $publisherId = $settings['content']['publisher_id'] ?? '';
+ if (empty($publisherId)) {
+ return;
+ }
+
+ $delayEnabled = ($settings['forms']['delay_enabled'] ?? true) === true;
+
+ if ($delayEnabled) {
+ echo '' . "\n";
+ } else {
+ echo '' . "\n";
+ }
+
+ } catch (\Throwable $e) {
+ if (defined('WP_DEBUG') && WP_DEBUG) {
+ error_log('ROI AdSense: ' . $e->getMessage());
+ }
+ }
+}
+add_action('wp_head', 'roi_enqueue_adsense_script', 5);
diff --git a/Public/AdsensePlacement/Infrastructure/Services/ContentAdInjector.php b/Public/AdsensePlacement/Infrastructure/Services/ContentAdInjector.php
new file mode 100644
index 00000000..b1f2ec3c
--- /dev/null
+++ b/Public/AdsensePlacement/Infrastructure/Services/ContentAdInjector.php
@@ -0,0 +1,73 @@
+settings['behavior']['post_content_enabled'] ?? false)) {
+ return $content;
+ }
+
+ // Verificar longitud minima
+ $minLength = (int)($this->settings['forms']['min_content_length'] ?? 500);
+ if (strlen(strip_tags($content)) < $minLength) {
+ return $content;
+ }
+
+ $afterParagraphs = (int)($this->settings['behavior']['post_content_after_paragraphs'] ?? 3);
+ $maxAds = (int)($this->settings['behavior']['post_content_max_ads'] ?? 2);
+
+ // Dividir contenido en parrafos
+ $paragraphs = explode('
', $content);
+ $totalParagraphs = count($paragraphs);
+
+ if ($totalParagraphs < $afterParagraphs + 1) {
+ return $content;
+ }
+
+ $adsInserted = 0;
+ $newContent = '';
+
+ foreach ($paragraphs as $index => $paragraph) {
+ $newContent .= $paragraph;
+
+ if ($index < $totalParagraphs - 1) {
+ $newContent .= '';
+ }
+
+ $paragraphNumber = $index + 1;
+
+ // Primer anuncio despues del parrafo indicado
+ if ($paragraphNumber === $afterParagraphs && $adsInserted < $maxAds) {
+ $newContent .= $this->renderer->renderSlot($this->settings, 'post-content-' . ($adsInserted + 1));
+ $adsInserted++;
+ }
+
+ // Segundo anuncio a mitad del contenido restante
+ $midPoint = $afterParagraphs + (int)(($totalParagraphs - $afterParagraphs) / 2);
+ if ($paragraphNumber === $midPoint && $adsInserted < $maxAds && $maxAds > 1) {
+ $newContent .= $this->renderer->renderSlot($this->settings, 'post-content-' . ($adsInserted + 1));
+ $adsInserted++;
+ }
+ }
+
+ return $newContent;
+ }
+}
diff --git a/Public/AdsensePlacement/Infrastructure/Ui/AdsensePlacementRenderer.php b/Public/AdsensePlacement/Infrastructure/Ui/AdsensePlacementRenderer.php
new file mode 100644
index 00000000..ba729065
--- /dev/null
+++ b/Public/AdsensePlacement/Infrastructure/Ui/AdsensePlacementRenderer.php
@@ -0,0 +1,356 @@
+getVisibilityClasses(
+ $settings['visibility']['show_on_desktop'] ?? true,
+ $settings['visibility']['show_on_mobile'] ?? true
+ );
+
+ if ($visibilityClasses === null) {
+ return ''; // Ambos false = no renderizar
+ }
+
+ // 3. Obtener configuracion de la ubicacion
+ $locationConfig = $this->getLocationConfig($settings, $location);
+ if (!$locationConfig['enabled']) {
+ return '';
+ }
+
+ // 4. Generar CSS (usando CSSGeneratorService)
+ $css = $this->cssGenerator->generate(
+ ".roi-ad-{$location}",
+ [
+ 'display' => 'flex',
+ 'justify_content' => 'center',
+ 'margin_top' => '1rem',
+ 'margin_bottom' => '1rem',
+ ]
+ );
+
+ // 5. Generar HTML del anuncio
+ $html = $this->buildAdHTML(
+ $settings,
+ $locationConfig['format'],
+ $location,
+ $visibilityClasses
+ );
+
+ return "\n{$html}";
+ }
+
+ /**
+ * Tabla de decision Bootstrap para visibilidad
+ */
+ private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
+ {
+ if (!$desktop && !$mobile) {
+ return null;
+ }
+ if (!$desktop && $mobile) {
+ return 'd-lg-none';
+ }
+ if ($desktop && !$mobile) {
+ return 'd-none d-lg-block';
+ }
+ return '';
+ }
+
+ /**
+ * Obtiene configuracion de una ubicacion especifica
+ */
+ private function getLocationConfig(array $settings, string $location): array
+ {
+ $locationKey = str_replace('-', '_', $location);
+
+ // Mapeo de ubicaciones a grupos y campos
+ $locationMap = [
+ 'post_top' => ['group' => 'behavior', 'enabled' => 'post_top_enabled', 'format' => 'post_top_format'],
+ 'post_bottom' => ['group' => 'behavior', 'enabled' => 'post_bottom_enabled', 'format' => 'post_bottom_format'],
+ 'after_related' => ['group' => 'behavior', 'enabled' => 'after_related_enabled', 'format' => 'after_related_format'],
+ 'archive_top' => ['group' => 'layout', 'enabled' => 'archive_top_enabled', 'format' => 'archive_format'],
+ 'archive_between' => ['group' => 'layout', 'enabled' => 'archive_between_enabled', 'format' => 'archive_format'],
+ 'archive_bottom' => ['group' => 'layout', 'enabled' => 'archive_bottom_enabled', 'format' => 'archive_format'],
+ 'header_below' => ['group' => 'layout', 'enabled' => 'header_below_enabled', 'format' => 'global_format'],
+ 'footer_above' => ['group' => 'layout', 'enabled' => 'footer_above_enabled', 'format' => 'global_format'],
+ ];
+
+ if (!isset($locationMap[$locationKey])) {
+ return ['enabled' => false, 'format' => 'auto'];
+ }
+
+ $map = $locationMap[$locationKey];
+ $group = $settings[$map['group']] ?? [];
+
+ return [
+ 'enabled' => $group[$map['enabled']] ?? false,
+ 'format' => $group[$map['format']] ?? 'auto',
+ ];
+ }
+
+ /**
+ * Genera HTML del bloque de anuncio
+ */
+ private function buildAdHTML(array $settings, string $format, string $location, string $visClasses): string
+ {
+ $publisherId = esc_attr($settings['content']['publisher_id'] ?? '');
+ $delayEnabled = ($settings['forms']['delay_enabled'] ?? true) === true;
+
+ if (empty($publisherId)) {
+ return '';
+ }
+
+ // Obtener slot segun formato
+ $slotId = $this->getSlotByFormat($settings, $format);
+ if (empty($slotId)) {
+ return '';
+ }
+
+ $scriptType = $delayEnabled ? 'text/plain' : 'text/javascript';
+ $dataAttr = $delayEnabled ? ' data-adsense-push' : '';
+ $locationClass = 'roi-ad-' . esc_attr(str_replace('_', '-', $location));
+
+ return $this->generateAdMarkup($format, $publisherId, $slotId, $locationClass, $visClasses, $scriptType, $dataAttr);
+ }
+
+ /**
+ * Obtiene el slot ID segun el formato
+ */
+ private function getSlotByFormat(array $settings, string $format): string
+ {
+ $content = $settings['content'] ?? [];
+
+ return match($format) {
+ 'display', 'display-large', 'display-square' => $content['slot_display'] ?? '',
+ 'in-article' => $content['slot_inarticle'] ?? '',
+ 'autorelaxed' => $content['slot_autorelaxed'] ?? '',
+ default => $content['slot_auto'] ?? '',
+ };
+ }
+
+ /**
+ * Genera el markup HTML segun formato de anuncio
+ *
+ * EXCEPCION DOCUMENTADA: CSS inline requerido por Google AdSense
+ * ----------------------------------------------------------------
+ * Los atributos style="display:inline-block", style="display:block",
+ * style="text-align:center", etc. son ESPECIFICACION DE GOOGLE y NO
+ * pueden generarse via CSSGenerator.
+ *
+ * Documentacion oficial:
+ * - https://support.google.com/adsense/answer/9274516
+ * - https://support.google.com/adsense/answer/9183460
+ *
+ * Estos estilos son necesarios para que AdSense funcione correctamente
+ * y son inyectados tal como Google los especifica en su documentacion.
+ */
+ private function generateAdMarkup(
+ string $format,
+ string $client,
+ string $slot,
+ string $locationClass,
+ string $visClasses,
+ string $scriptType,
+ string $dataAttr
+ ): string {
+ $allClasses = trim("{$locationClass} {$visClasses}");
+
+ return match($format) {
+ 'display' => $this->adDisplay($client, $slot, 728, 90, $allClasses, $scriptType, $dataAttr),
+ 'display-large' => $this->adDisplay($client, $slot, 970, 250, $allClasses, $scriptType, $dataAttr),
+ 'display-square' => $this->adDisplay($client, $slot, 300, 250, $allClasses, $scriptType, $dataAttr),
+ 'in-article' => $this->adInArticle($client, $slot, $allClasses, $scriptType, $dataAttr),
+ 'autorelaxed' => $this->adAutorelaxed($client, $slot, $allClasses, $scriptType, $dataAttr),
+ default => $this->adAuto($client, $slot, $allClasses, $scriptType, $dataAttr),
+ };
+ }
+
+ private function adDisplay(string $c, string $s, int $w, int $h, string $cl, string $t, string $a): string
+ {
+ return sprintf(
+ '
+
+
+
',
+ esc_attr($cl), $w, $h, esc_attr($c), esc_attr($s), $t, $a
+ );
+ }
+
+ private function adAuto(string $c, string $s, string $cl, string $t, string $a): string
+ {
+ return sprintf(
+ '
+
+
+
',
+ esc_attr($cl), esc_attr($c), esc_attr($s), $t, $a
+ );
+ }
+
+ private function adInArticle(string $c, string $s, string $cl, string $t, string $a): string
+ {
+ return sprintf(
+ '
+
+
+
',
+ esc_attr($cl), esc_attr($c), esc_attr($s), $t, $a
+ );
+ }
+
+ private function adAutorelaxed(string $c, string $s, string $cl, string $t, string $a): string
+ {
+ return sprintf(
+ '
+
+
+
',
+ esc_attr($cl), esc_attr($c), esc_attr($s), $t, $a
+ );
+ }
+
+ /**
+ * Renderiza Rail Ads (anuncios fijos en margenes laterales)
+ * Se inyectan via wp_footer y usan position: fixed
+ */
+ public function renderRailAds(array $settings): string
+ {
+ // Verificar si Rail Ads estan habilitados
+ if (!($settings['visibility']['is_enabled'] ?? false)) {
+ return '';
+ }
+ if (!($settings['behavior']['rail_ads_enabled'] ?? false)) {
+ return '';
+ }
+
+ $publisherId = esc_attr($settings['content']['publisher_id'] ?? '');
+ $slotId = $settings['content']['slot_skyscraper'] ?? '';
+
+ if (empty($publisherId) || empty($slotId)) {
+ return '';
+ }
+
+ $leftEnabled = ($settings['behavior']['rail_left_enabled'] ?? true) === true;
+ $rightEnabled = ($settings['behavior']['rail_right_enabled'] ?? true) === true;
+ $format = $settings['behavior']['rail_format'] ?? 'skyscraper';
+ $topOffset = (int)($settings['behavior']['rail_top_offset'] ?? 150);
+ $delayEnabled = ($settings['forms']['delay_enabled'] ?? true) === true;
+
+ // Dimensiones segun formato
+ $width = $format === 'half-page' ? 300 : 160;
+ $height = 600;
+
+ $scriptType = $delayEnabled ? 'text/plain' : 'text/javascript';
+ $dataAttr = $delayEnabled ? ' data-adsense-push' : '';
+
+ // === CSS via CSSGenerator (NO hardcodeado) ===
+ $cssRules = [];
+
+ // Estilos base para Rail Ads
+ $cssRules[] = $this->cssGenerator->generate('.roi-rail-ad', [
+ 'position' => 'fixed',
+ 'top' => $topOffset . 'px',
+ 'width' => $width . 'px',
+ 'z-index' => '100',
+ ]);
+
+ // Posicion rail izquierdo
+ $cssRules[] = $this->cssGenerator->generate('.roi-rail-ad-left', [
+ 'left' => 'calc((100vw - 1320px) / 2 - ' . ($width + 20) . 'px)',
+ ]);
+
+ // Posicion rail derecho
+ $cssRules[] = $this->cssGenerator->generate('.roi-rail-ad-right', [
+ 'right' => 'calc((100vw - 1320px) / 2 - ' . ($width + 20) . 'px)',
+ ]);
+
+ // Media query para ocultar en pantallas < 1600px
+ // NOTA: Media queries se escriben directamente (patron consistente con FeaturedImageRenderer)
+ $cssRules[] = "@media (max-width: 1599px) {
+ .roi-rail-ad { display: none !important; }
+ }";
+
+ $css = implode("\n", $cssRules);
+ $html = "\n";
+
+ /**
+ * EXCEPCION DOCUMENTADA: CSS inline requerido por Google AdSense
+ * Los atributos style="display:inline-block;width:Xpx;height:Xpx" son
+ * especificacion de Google y NO pueden generarse via CSSGenerator.
+ * Ref: https://support.google.com/adsense/answer/9274516
+ */
+
+ // Rail izquierdo
+ if ($leftEnabled) {
+ $html .= sprintf(
+ '
+
+
+
',
+ $width, $height, esc_attr($publisherId), esc_attr($slotId), $scriptType, $dataAttr
+ );
+ }
+
+ // Rail derecho
+ if ($rightEnabled) {
+ $html .= sprintf(
+ '
+
+
+
',
+ $width, $height, esc_attr($publisherId), esc_attr($slotId), $scriptType, $dataAttr
+ );
+ }
+
+ return $html;
+ }
+}
diff --git a/Shared/Infrastructure/Di/DIContainer.php b/Shared/Infrastructure/Di/DIContainer.php
index 8da6cac5..79f8d0af 100644
--- a/Shared/Infrastructure/Di/DIContainer.php
+++ b/Shared/Infrastructure/Di/DIContainer.php
@@ -19,6 +19,7 @@ use ROITheme\Shared\Infrastructure\Services\CleanupService;
use ROITheme\Shared\Infrastructure\Services\CSSGeneratorService;
use ROITheme\Shared\Application\UseCases\GetComponentSettings\GetComponentSettingsUseCase;
use ROITheme\Shared\Application\UseCases\SaveComponentSettings\SaveComponentSettingsUseCase;
+use ROITheme\Public\AdsensePlacement\Infrastructure\Ui\AdsensePlacementRenderer;
/**
* DIContainer - Contenedor de Inyección de Dependencias
@@ -233,4 +234,23 @@ final class DIContainer
return $this->instances['saveComponentSettingsUseCase'];
}
+
+ /**
+ * Obtener renderer de AdSense Placement
+ *
+ * Lazy initialization: Crea la instancia solo en la primera llamada
+ * Resuelve dependencia: getCSSGeneratorService()
+ *
+ * @return AdsensePlacementRenderer
+ */
+ public function getAdsensePlacementRenderer(): AdsensePlacementRenderer
+ {
+ if (!isset($this->instances['adsensePlacementRenderer'])) {
+ $this->instances['adsensePlacementRenderer'] = new AdsensePlacementRenderer(
+ $this->getCSSGeneratorService()
+ );
+ }
+
+ return $this->instances['adsensePlacementRenderer'];
+ }
}
diff --git a/functions.php b/functions.php
index d81e9d2b..c482b8ac 100644
--- a/functions.php
+++ b/functions.php
@@ -49,6 +49,7 @@ require_once get_template_directory() . '/Inc/template-tags.php';
require_once get_template_directory() . '/Inc/featured-image.php';
require_once get_template_directory() . '/Inc/category-badge.php';
require_once get_template_directory() . '/Inc/adsense-delay.php';
+require_once get_template_directory() . '/Inc/adsense-placement.php';
require_once get_template_directory() . '/Inc/related-posts.php';
// ELIMINADO: Inc/toc.php (FASE 6 - Clean Architecture: usa TableOfContentsRenderer)
require_once get_template_directory() . '/Inc/apu-tables.php';