diff --git a/Admin/AdsensePlacement/Infrastructure/FieldMapping/AdsensePlacementFieldMapper.php b/Admin/AdsensePlacement/Infrastructure/FieldMapping/AdsensePlacementFieldMapper.php index dbd67be0..6170f86c 100644 --- a/Admin/AdsensePlacement/Infrastructure/FieldMapping/AdsensePlacementFieldMapper.php +++ b/Admin/AdsensePlacement/Infrastructure/FieldMapping/AdsensePlacementFieldMapper.php @@ -118,6 +118,27 @@ final class AdsensePlacementFieldMapper implements FieldMapperInterface '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'], ]; } } diff --git a/Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php b/Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php index 3a40fd24..f5c5e1c4 100644 --- a/Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php +++ b/Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php @@ -47,6 +47,7 @@ final class AdsensePlacementFormBuilder $html .= $this->buildVisibilityGroup($componentId); $html .= $this->buildDiagramSection(); $html .= $this->buildPostLocationsGroup($componentId); + $html .= $this->buildInContentAdvancedGroup($componentId); $html .= $this->buildInContentAdsGroup($componentId); $html .= $this->buildExclusionsGroup($componentId); $html .= ' '; @@ -342,6 +343,290 @@ final class AdsensePlacementFormBuilder return $html; } + + /** + * Seccion avanzada para In-Content Ads con multiples tipos de ubicacion + * Incluye: modo de densidad, ubicaciones por elemento, limites y espaciado + */ + private function buildInContentAdvancedGroup(string $cid): string + { + // Obtener valores actuales del grupo incontent_advanced + $mode = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_mode', 'paragraphs_only'); + $mode = is_string($mode) ? $mode : 'paragraphs_only'; + + // Ubicaciones + $h2Enabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_h2_enabled', true); + $h2Prob = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_h2_probability', '100'); + $h3Enabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_h3_enabled', true); + $h3Prob = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_h3_probability', '50'); + $paragraphsEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_paragraphs_enabled', true); + $paragraphsProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_paragraphs_probability', '75'); + $imagesEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_images_enabled', true); + $imagesProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_images_probability', '75'); + $listsEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_lists_enabled', false); + $listsProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_lists_probability', '50'); + $blockquotesEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_blockquotes_enabled', false); + $blockquotesProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_blockquotes_probability', '50'); + $tablesEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_tables_enabled', false); + $tablesProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_tables_probability', '50'); + + // Limites + $maxAds = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_max_total_ads', '8'); + $minSpacing = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_min_spacing', '3'); + $format = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_format', 'in-article'); + $priorityMode = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_priority_mode', 'position'); + + // Cast to string where needed + $h2Prob = is_string($h2Prob) ? $h2Prob : '100'; + $h3Prob = is_string($h3Prob) ? $h3Prob : '50'; + $paragraphsProb = is_string($paragraphsProb) ? $paragraphsProb : '75'; + $imagesProb = is_string($imagesProb) ? $imagesProb : '75'; + $listsProb = is_string($listsProb) ? $listsProb : '50'; + $blockquotesProb = is_string($blockquotesProb) ? $blockquotesProb : '50'; + $tablesProb = is_string($tablesProb) ? $tablesProb : '50'; + $maxAds = is_string($maxAds) ? $maxAds : '8'; + $minSpacing = is_string($minSpacing) ? $minSpacing : '3'; + $format = is_string($format) ? $format : 'in-article'; + $priorityMode = is_string($priorityMode) ? $priorityMode : 'position'; + + $isParagraphsOnly = $mode === 'paragraphs_only'; + $disabledAttr = $isParagraphsOnly ? 'disabled' : ''; + + $html = '
'; + $html .= '
'; + $html .= '
'; + $html .= ' '; + $html .= ' In-Content Ads Avanzado'; + $html .= ' Nuevo'; + $html .= '
'; + + // Indicador de densidad + $html .= '
'; + $html .= ' '; + $html .= ' Densidad estimada: Calculando...'; + $html .= ' ~? ads'; + $html .= '
'; + + // Banner informativo para modo Solo parrafos + $html .= '
'; + $html .= ' '; + $html .= ' Solo parrafos: 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 .= '
'; + + // Selector de modo con descripciones + $html .= '
'; + $html .= ' '; + $html .= '

Define donde y con que frecuencia se insertaran anuncios dentro del contenido.

'; + $html .= ' '; + + // Descripciones de cada modo + $html .= '
'; + + // Solo parrafos + $html .= '
'; + $html .= ' Solo parrafos'; + $html .= '

Inserta anuncios unicamente despues de parrafos. Usa la configuracion de la seccion "Post Content" (numero de anuncios, parrafos entre ads, etc).

'; + $html .= ' Ideal si: Tu contenido tiene pocos encabezados o prefieres la configuracion tradicional.'; + $html .= '
'; + + // Conservador + $html .= '
'; + $html .= ' Conservador'; + $html .= '

Maximo 5 anuncios con espaciado amplio (5 elementos). Solo inserta despues de titulos H2 y parrafos.

'; + $html .= ' Ideal si: Priorizas la experiencia del usuario sobre los ingresos. Articulos cortos o medianos.'; + $html .= '
'; + + // Balanceado + $html .= '
'; + $html .= ' Balanceado'; + $html .= '

Hasta 8 anuncios con espaciado moderado (3 elementos). Usa H2, H3, parrafos e imagenes.

'; + $html .= ' Ideal si: Buscas equilibrio entre ingresos y experiencia. Articulos medianos a largos.'; + $html .= '
'; + + // Intensivo + $html .= '
'; + $html .= ' Intensivo'; + $html .= '

Hasta 15 anuncios con espaciado minimo (2 elementos). Usa todos los tipos de elementos disponibles.

'; + $html .= ' Ideal si: Priorizas maximizar ingresos. Solo para articulos muy largos (+3000 palabras).'; + $html .= '
'; + + // Personalizado + $html .= '
'; + $html .= ' Personalizado'; + $html .= '

Tu configuras manualmente cada ubicacion, probabilidad y limites.

'; + $html .= ' Ideal si: Quieres control total sobre donde aparecen los anuncios.'; + $html .= '
'; + + $html .= '
'; + $html .= '
'; + + // Subseccion: Ubicaciones por elemento + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= ' Ubicaciones por Elemento'; + $html .= ' '; + $html .= '
'; + + // 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 .= '
'; + $html .= '
'; + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= '
'; + $html .= '
'; + $html .= '
'; + $html .= ' '; + $html .= '
'; + $html .= '
'; + } + + $html .= '
'; + $html .= '
'; + + // Subseccion: Limites y espaciado + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= ' Limites y Espaciado'; + $html .= ' '; + $html .= '
'; + $html .= '
'; + + // Max total ads + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= '
'; + + // Min spacing + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= '
'; + + // Formato de ads + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= '
'; + + // Priority mode + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= ' Como resolver conflictos cuando dos ubicaciones estan muy cerca'; + $html .= '
'; + + $html .= '
'; + $html .= '
'; + $html .= '
'; + + // Warning para densidad alta + $html .= '
'; + $html .= ' '; + $html .= ' Atencion: Densidad alta (>10 ads) puede afectar UX y violar politicas de AdSense.'; + $html .= '
'; + + $html .= '
'; + $html .= '
'; + + return $html; + } + /** * Seccion especial para in-content ads con configuracion de 1-8 random */ diff --git a/Admin/Infrastructure/Ui/Assets/Js/admin-dashboard.js b/Admin/Infrastructure/Ui/Assets/Js/admin-dashboard.js index 8c175645..14c13701 100644 --- a/Admin/Infrastructure/Ui/Assets/Js/admin-dashboard.js +++ b/Admin/Infrastructure/Ui/Assets/Js/admin-dashboard.js @@ -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 = ` + + `; + 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(); + } + })(); diff --git a/Public/AdsensePlacement/Infrastructure/Services/ContentAdInjector.php b/Public/AdsensePlacement/Infrastructure/Services/ContentAdInjector.php index 109f21a9..a1746997 100644 --- a/Public/AdsensePlacement/Infrastructure/Services/ContentAdInjector.php +++ b/Public/AdsensePlacement/Infrastructure/Services/ContentAdInjector.php @@ -9,13 +9,30 @@ use ROITheme\Public\AdsensePlacement\Infrastructure\Ui\AdsensePlacementRenderer; * Inyecta anuncios dentro del contenido del post * via filtro the_content * - * Soporta: - * - Modo aleatorio (random) con posiciones variables - * - Configuracion de 1-8 ads maximo - * - Espacio minimo entre anuncios + * Soporta dos modos: + * - Solo parrafos: Logica clasica solo con parrafos (usa config de behavior) + * - Avanzado: Multiples tipos de elementos (H2, H3, p, img, lists, blockquotes, tables) + * + * El modo se determina por incontent_mode: + * - "paragraphs_only": usa config de behavior (insercion solo en parrafos) + * - Otros: usa config de incontent_advanced */ final class ContentAdInjector { + /** + * Prioridades de elementos para seleccion + * Mayor = mas importante + */ + private const ELEMENT_PRIORITIES = [ + 'h2' => 10, + 'p' => 8, + 'h3' => 7, + 'image' => 6, + 'list' => 5, + 'blockquote' => 4, + 'table' => 3, + ]; + public function __construct( private array $settings, private AdsensePlacementRenderer $renderer @@ -26,17 +43,32 @@ final class ContentAdInjector */ public function inject(string $content): string { - if (!($this->settings['behavior']['post_content_enabled'] ?? false)) { - return $content; - } - - // Verificar longitud minima + // 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 $content; } - // Obtener configuracion + // Determinar modo de operacion + $mode = $this->settings['incontent_advanced']['incontent_mode'] ?? 'paragraphs_only'; + + if ($mode === 'paragraphs_only') { + return $this->injectParagraphsOnly($content); + } + + return $this->injectAdvanced($content); + } + + /** + * Modo solo parrafos: logica clasica que inserta anuncios unicamente despues de parrafos + */ + private function injectParagraphsOnly(string $content): string + { + if (!($this->settings['behavior']['post_content_enabled'] ?? false)) { + return $content; + } + + // Obtener configuracion de behavior (modo solo parrafos) $minAds = (int)($this->settings['behavior']['post_content_min_ads'] ?? 1); $maxAds = (int)($this->settings['behavior']['post_content_max_ads'] ?? 3); $afterParagraphs = (int)($this->settings['behavior']['post_content_after_paragraphs'] ?? 3); @@ -58,7 +90,7 @@ final class ContentAdInjector } // Calcular posiciones de insercion - $adPositions = $this->calculateAdPositions( + $adPositions = $this->calculateParagraphsOnlyPositions( $totalParagraphs, $afterParagraphs, $minBetween, @@ -72,9 +104,354 @@ final class ContentAdInjector } // Reconstruir contenido con anuncios insertados - return $this->buildContentWithAds($paragraphs, $adPositions); + return $this->buildParagraphsOnlyContent($paragraphs, $adPositions); } + /** + * Modo avanzado: multiples tipos de elementos + */ + private function injectAdvanced(string $content): string + { + $config = $this->settings['incontent_advanced'] ?? []; + + // 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'; + + // PASO 1: Escanear contenido para encontrar todas las ubicaciones + $locations = $this->scanContent($content); + + if (empty($locations)) { + return $content; + } + + // PASO 2: Filtrar por configuracion (enabled) + $locations = $this->filterByEnabled($locations, $config); + + if (empty($locations)) { + return $content; + } + + // PASO 3: Aplicar probabilidad deterministica + $postId = get_the_ID() ?: 0; + $locations = $this->applyProbability($locations, $config, $postId); + + if (empty($locations)) { + return $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); + } + + if (empty($locations)) { + return $content; + } + + // Ordenar por posicion para insercion correcta + usort($locations, fn($a, $b) => $a['position'] <=> $b['position']); + + // PASO 6: Insertar anuncios + return $this->insertAds($content, $locations, $format); + } + + /** + * PASO 1: Escanea el contenido para encontrar ubicaciones elegibles + * + * @return array{position: int, type: string, tag: string, element_index: int}[] + */ + private function scanContent(string $content): array + { + $locations = []; + $elementIndex = 0; + + // Regex para encontrar tags de cierre de elementos de bloque + $pattern = '/(<\/(?:p|h2|h3|figure|ul|ol|table|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 + + $type = $this->getTypeFromTag($tag); + if ($type) { + $locations[] = [ + 'position' => $position, + 'type' => $type, + 'tag' => $tag, + 'element_index' => $elementIndex++, + ]; + } + } + } + + // Detectar imagenes standalone (no dentro de figure) + $locations = array_merge($locations, $this->scanStandaloneImages($content, $elementIndex)); + + // 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; + } + + /** + * Convierte tag de cierre a tipo de elemento + */ + private function getTypeFromTag(string $tag): ?string + { + return match ($tag) { + '

' => 'p', + '' => 'h2', + '' => 'h3', + '' => 'image', + '', '' => 'list', + '' => 'table', + '' => 'blockquote', + default => null, + }; + } + + /** + * Detecta imagenes que no estan dentro de figure + */ + private function scanStandaloneImages(string $content, int $startIndex): array + { + $locations = []; + + // Encontrar todas las imagenes con sus posiciones + if (!preg_match_all('/]*>/i', $content, $matches, PREG_OFFSET_CAPTURE)) { + return $locations; + } + + foreach ($matches[0] as $match) { + $imgTag = $match[0]; + $imgPosition = $match[1]; + + // Verificar si hay un
abierto antes de esta imagen + $contentBefore = substr($content, 0, $imgPosition); + $lastFigureOpen = strrpos($contentBefore, ''); + + // Si hay figure abierto sin cerrar, esta imagen esta dentro de figure + if ($lastFigureOpen !== false && ($lastFigureClose === false || $lastFigureClose < $lastFigureOpen)) { + continue; // Ignorar, se contara con
+ } + + // Imagen standalone - calcular posicion despues del tag + $endPosition = $imgPosition + strlen($imgTag); + + // Si la imagen esta seguida de

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); // ->