fix(analytics): Corregir URLs en analytics usando post_name desde BD

Problema:
- URLs en analytics mostraban dominio incorrecto (HTTP_HOST)
- URLs usaban formato /?p=ID en lugar de permalinks

Solución:
- class-search-engine.php: Agregar propiedad $prefix, incluir p.post_name
  en 5 queries fetch, agregar helpers getSiteUrlFromDb(),
  getPermalinkStructure() y buildPermalink()
- search-endpoint.php: Obtener site_url y permalink_structure desde
  wp_options, construir URLs con post_name
- click-endpoint.php: Fallback de dest usando post_name desde BD

Archivos modificados:
- includes/class-search-engine.php
- api/search-endpoint.php
- api/click-endpoint.php

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-12-03 10:59:56 -06:00
commit 81d65c0f9a
10 changed files with 3220 additions and 0 deletions

704
assets/js/search-handler.js Normal file
View File

@@ -0,0 +1,704 @@
/**
* ROI APU Search Handler
*
* Handles AJAX search requests with debouncing and pagination
*
* @package ROI_APU_Search
*/
(function() {
'use strict';
// Configuration from WordPress
const config = window.roiApuSearch || {
ajaxUrl: '/wp-admin/admin-ajax.php',
apiUrl: '',
clickUrl: '',
nonce: '',
ads: { enabled: false }
};
// State management per instance
const instances = new Map();
// =============================================================================
// FUNCIONES DE ADSENSE
// =============================================================================
/**
* Genera HTML de anuncio AdSense
* @param {string} format - Formato del anuncio (auto, in-article, autorelaxed, display)
* @param {string|number} position - Posicion del anuncio para identificacion
* @returns {string} HTML del anuncio o string vacio si hay error
*/
function generateAdHtml(format, position) {
try {
var ads = config.ads;
if (!ads || !ads.enabled) return '';
var slotInfo = getSlotAndFormatByType(format, ads.slots);
if (!slotInfo.slot || !ads.publisherId) return '';
// Determinar tipo de script segun delay
var scriptType = ads.delay && ads.delay.enabled ? 'text/plain' : 'text/javascript';
var dataAttr = ads.delay && ads.delay.enabled ? ' data-adsense-push' : '';
// Generar atributos segun formato
var formatAttrs = getAdFormatAttributes(format);
return '<div class="roi-apu-ad-item" data-ad-position="' + position + '">' +
'<ins class="adsbygoogle" ' +
'style="display:block;min-height:250px" ' +
'data-ad-client="' + ads.publisherId + '" ' +
'data-ad-slot="' + slotInfo.slot + '" ' +
formatAttrs + '></ins>' +
'<script type="' + scriptType + '"' + dataAttr + '>' +
'try { (adsbygoogle = window.adsbygoogle || []).push({}); } catch(e) { console.warn("AdSense push failed:", e); }' +
'<\/script>' +
'</div>';
} catch (e) {
console.warn('generateAdHtml error:', e);
return '';
}
}
/**
* Obtiene el slot y formato correcto segun el tipo
* @param {string} format - Tipo de formato solicitado
* @param {object} slots - Objeto con los slots disponibles
* @returns {object} {slot, format}
*/
function getSlotAndFormatByType(format, slots) {
if (!slots) return { slot: null, format: 'auto' };
switch(format) {
case 'in-article':
return { slot: slots.inArticle || slots.auto, format: 'fluid' };
case 'autorelaxed':
return { slot: slots.autorelaxed || slots.auto, format: 'autorelaxed' };
case 'display':
return { slot: slots.display || slots.auto, format: 'rectangle' };
case 'auto':
default:
return { slot: slots.auto, format: 'auto' };
}
}
/**
* Genera atributos HTML segun el formato de anuncio
* @param {string} format - Tipo de formato
* @returns {string} Atributos HTML
*/
function getAdFormatAttributes(format) {
switch(format) {
case 'in-article':
return 'data-ad-layout="in-article" data-ad-format="fluid"';
case 'autorelaxed':
return 'data-ad-format="autorelaxed"';
case 'display':
return 'data-ad-format="rectangle" data-full-width-responsive="false"';
case 'auto':
default:
return 'data-ad-format="auto" data-full-width-responsive="true"';
}
}
/**
* Mezcla un array aleatoriamente (Fisher-Yates)
* @param {Array} array - Array a mezclar
* @returns {Array} Array mezclado
*/
function shuffleArray(array) {
var arr = array.slice(); // Copia para no mutar original
for (var i = arr.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
return arr;
}
/**
* Calcula posiciones donde insertar anuncios
* @param {number} totalResults - Total de resultados de busqueda
* @param {object} adsConfig - Configuracion de ads.betweenAds
* @returns {number[]} Array de posiciones donde insertar anuncios
*/
function calculateAdPositions(totalResults, adsConfig) {
try {
if (!adsConfig || !adsConfig.enabled || totalResults < 2) return [];
// Max 3 por politicas AdSense, y no mas que resultados-1
var max = Math.min(adsConfig.max || 1, totalResults - 1, 3);
var positions = [];
var pool, i;
if (adsConfig.position === 'random') {
// Posiciones aleatorias entre resultados
pool = [];
for (i = 1; i < totalResults; i++) {
pool.push(i);
}
return shuffleArray(pool).slice(0, max);
} else if (adsConfig.position === 'fixed') {
// Cada N resultados
var every = adsConfig.every || 5;
for (i = every; i <= totalResults && positions.length < max; i += every) {
positions.push(i);
}
} else if (adsConfig.position === 'first_half') {
// Solo en la primera mitad de resultados
var halfPoint = Math.ceil(totalResults / 2);
pool = [];
for (i = 1; i < halfPoint; i++) {
pool.push(i);
}
return shuffleArray(pool).slice(0, max);
}
return positions;
} catch (e) {
console.warn('calculateAdPositions error:', e);
return [];
}
}
/**
* Activa slots de AdSense despues de insertar en DOM
*/
function activateAdsenseSlots() {
try {
var ads = config.ads;
if (!ads || !ads.enabled) return;
if (ads.delay && ads.delay.enabled) {
// Disparar evento para que adsense-loader.js active los scripts
window.dispatchEvent(new CustomEvent('roi-adsense-activate'));
} else {
// Delay deshabilitado - los scripts inline ya se ejecutaron
// pero debemos asegurar que adsbygoogle.push() se llame
// para slots insertados dinamicamente
setTimeout(function() {
var newSlots = document.querySelectorAll('.roi-apu-ad-item ins.adsbygoogle:not([data-adsbygoogle-status])');
if (newSlots.length > 0 && typeof adsbygoogle !== 'undefined') {
newSlots.forEach(function() {
try {
(adsbygoogle = window.adsbygoogle || []).push({});
} catch(e) {
// Ignorar - el slot ya fue procesado
}
});
}
}, 100);
}
// Observar estado de los ads para mostrar solo cuando esten llenos
watchAdSlots();
} catch (e) {
console.warn('activateAdsenseSlots error:', e);
}
}
/**
* Observa los slots de AdSense y oculta los que no se llenan
* Los ads estan VISIBLES por defecto via CSS para que AdSense pueda medirlos
* Si no se llenan despues del timeout, se ocultan con clase .ad-unfilled
* AdSense agrega data-ad-status="filled" o "unfilled" cuando procesa el slot
*/
function watchAdSlots() {
var adContainers = document.querySelectorAll('.roi-apu-ad-item:not(.ad-watched)');
if (!adContainers.length) return;
adContainers.forEach(function(container) {
// Marcar como observado para evitar duplicados
container.classList.add('ad-watched');
var ins = container.querySelector('ins.adsbygoogle');
if (!ins) {
// Sin elemento ins, ocultar
container.classList.add('ad-unfilled');
return;
}
// Funcion para verificar estado y actuar
function checkStatus() {
var status = ins.getAttribute('data-ad-status');
if (status === 'filled') {
// Ad llenado correctamente - mantener visible
container.classList.remove('ad-unfilled');
return true;
} else if (status === 'unfilled') {
// Ad no llenado - ocultar
container.classList.add('ad-unfilled');
return true;
}
return false; // Status aun no definido
}
// Verificar si ya tiene estado
if (checkStatus()) {
return;
}
// Usar MutationObserver para detectar cuando AdSense procesa el slot
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-ad-status') {
if (checkStatus()) {
observer.disconnect();
}
}
});
});
observer.observe(ins, {
attributes: true,
attributeFilter: ['data-ad-status']
});
// Timeout de seguridad: despues de 5s si no hay status, ocultar
// Esto maneja el caso donde AdSense no establece data-ad-status
setTimeout(function() {
observer.disconnect();
var status = ins.getAttribute('data-ad-status');
if (status !== 'filled') {
// No se lleno o sin respuesta - ocultar para evitar espacio vacio
container.classList.add('ad-unfilled');
}
}, 5000);
});
}
/**
* Initialize all search containers on the page
*/
function init() {
const containers = document.querySelectorAll('.roi-apu-search-container');
containers.forEach(initContainer);
}
/**
* Initialize a single search container
*/
function initContainer(container) {
const id = container.id;
if (instances.has(id)) return;
const state = {
container,
input: container.querySelector('.roi-apu-search-input'),
button: container.querySelector('.roi-apu-search-btn'),
errorEl: container.querySelector('.roi-apu-error'),
loadingEl: container.querySelector('.roi-apu-loading'),
infoEl: container.querySelector('.roi-apu-info'),
resultsEl: container.querySelector('.roi-apu-results'),
paginationEl: container.querySelector('.roi-apu-pagination'),
categories: container.dataset.categories || '',
perPage: parseInt(container.dataset.perPage, 10) || 10,
minChars: parseInt(container.dataset.minChars, 10) || 3,
showInfo: container.dataset.showInfo === 'true',
currentPage: 1,
currentTerm: '',
totalResults: 0,
searchId: null,
debounceTimer: null,
abortController: null
};
instances.set(id, state);
// Event listeners
state.input.addEventListener('input', () => handleInput(state));
state.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
doSearch(state);
}
});
state.button.addEventListener('click', () => doSearch(state));
}
/**
* Handle input with debounce
*/
function handleInput(state) {
clearTimeout(state.debounceTimer);
hideError(state);
const term = state.input.value.trim();
if (term.length === 0) {
clearResults(state);
return;
}
if (term.length < state.minChars) {
return;
}
// Debounce search
state.debounceTimer = setTimeout(() => {
doSearch(state);
}, 300);
}
/**
* Execute search
*/
async function doSearch(state, page = 1) {
const term = state.input.value.trim();
if (term.length < state.minChars) {
showError(state, `Ingresa al menos ${state.minChars} caracteres`);
return;
}
// Cancel previous request
if (state.abortController) {
state.abortController.abort();
}
state.abortController = new AbortController();
state.currentTerm = term;
state.currentPage = page;
showLoading(state);
hideError(state);
try {
const formData = new FormData();
formData.append('action', 'roi_apu_search');
formData.append('nonce', config.nonce);
formData.append('term', term);
formData.append('page', page.toString());
formData.append('per_page', state.perPage.toString());
if (state.categories) {
formData.append('categories', state.categories);
}
// Try fast endpoint first, fallback to admin-ajax
let url = config.apiUrl || config.ajaxUrl;
let response;
try {
response = await fetch(url, {
method: 'POST',
body: formData,
signal: state.abortController.signal
});
} catch (err) {
// Fallback to admin-ajax if fast endpoint fails
if (url !== config.ajaxUrl) {
url = config.ajaxUrl;
response = await fetch(url, {
method: 'POST',
body: formData,
signal: state.abortController.signal
});
} else {
throw err;
}
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.data?.message || 'Error en la busqueda');
}
state.totalResults = data.data.total || 0;
state.searchId = data.data.search_id || null;
renderResults(state, data.data);
} catch (err) {
if (err.name === 'AbortError') {
return; // Ignore aborted requests
}
console.error('ROI APU Search Error:', err);
showError(state, 'Error al realizar la busqueda. Intenta de nuevo.');
clearResults(state);
} finally {
hideLoading(state);
}
}
/**
* Render search results (con soporte para ads)
*/
function renderResults(state, data) {
var rows = data.rows;
var total = data.total;
var time_ms = data.time_ms;
var ads = config.ads;
// Show info
if (state.showInfo && total > 0) {
state.infoEl.innerHTML =
'<span class="roi-apu-info-total">' + formatNumber(total) + ' resultados</span>' +
'<span class="roi-apu-info-time">' + time_ms + 'ms</span>';
state.infoEl.style.display = 'flex';
} else {
state.infoEl.style.display = 'none';
}
// Render no results message
if (rows.length === 0) {
state.resultsEl.innerHTML =
'<div class="roi-apu-no-results">' +
'<p>No se encontraron resultados para "<strong>' + escapeHtml(state.currentTerm) + '</strong>"</p>' +
'<p class="roi-apu-suggestions">Intenta con terminos mas generales o revisa la ortografia.</p>' +
'</div>';
state.paginationEl.style.display = 'none';
return;
}
// Calcular posiciones de anuncios (solo si hay resultados y ads habilitados)
var adPositions = {};
if (ads && ads.enabled && ads.betweenAds && ads.betweenAds.enabled && rows.length > 1) {
var positions = calculateAdPositions(rows.length, ads.betweenAds);
for (var p = 0; p < positions.length; p++) {
adPositions[positions[p]] = true;
}
}
// Generar anuncio superior (solo en primera pagina y si hay resultados)
var topAdHtml = '';
if (ads && ads.enabled && ads.topAd && ads.topAd.enabled && state.currentPage === 1 && total > 0) {
topAdHtml = generateAdHtml(ads.topAd.format, 'top');
}
// Renderizar resultados con anuncios intercalados
var resultsHtml = '';
for (var index = 0; index < rows.length; index++) {
var row = rows[index];
var position = (state.currentPage - 1) * state.perPage + index + 1;
var title = highlightTerm(row.post_title, state.currentTerm);
var date = formatDate(row.post_date);
// Build click tracking URL if available
var href = row.permalink;
if (config.clickUrl && state.searchId) {
var params = new URLSearchParams({
sid: state.searchId,
pid: row.ID,
pos: position,
page: state.currentPage,
dest: row.permalink
});
href = config.clickUrl + '?' + params.toString();
}
resultsHtml +=
'<a href="' + escapeHtml(href) + '" class="roi-apu-result-item" target="_blank" rel="noopener">' +
'<span class="roi-apu-result-position">' + position + '</span>' +
'<div class="roi-apu-result-content">' +
'<h3 class="roi-apu-result-title">' + title + '</h3>' +
'<span class="roi-apu-result-date">' + date + '</span>' +
'</div>' +
'<svg class="roi-apu-result-arrow" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">' +
'<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>' +
'</svg>' +
'</a>';
// Insertar anuncio despues de este item si corresponde
if (adPositions[index + 1]) {
resultsHtml += generateAdHtml(ads.betweenAds.format, 'between-' + (index + 1));
}
}
// Construir HTML final
state.resultsEl.innerHTML = topAdHtml + '<div class="roi-apu-results-list">' + resultsHtml + '</div>';
// Render pagination
renderPagination(state);
// Activar anuncios despues de insertar en DOM
var hasAds = topAdHtml || Object.keys(adPositions).length > 0;
if (ads && ads.enabled && hasAds) {
activateAdsenseSlots();
}
}
/**
* Render pagination controls
*/
function renderPagination(state) {
const totalPages = Math.ceil(state.totalResults / state.perPage);
if (totalPages <= 1) {
state.paginationEl.style.display = 'none';
return;
}
const currentPage = state.currentPage;
let html = '<div class="roi-apu-pagination-inner">';
// Previous button
if (currentPage > 1) {
html += `<button type="button" class="roi-apu-page-btn roi-apu-prev" data-page="${currentPage - 1}">&laquo; Anterior</button>`;
}
// Page numbers
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, startPage + 4);
if (startPage > 1) {
html += `<button type="button" class="roi-apu-page-btn" data-page="1">1</button>`;
if (startPage > 2) {
html += `<span class="roi-apu-page-ellipsis">...</span>`;
}
}
for (let i = startPage; i <= endPage; i++) {
const activeClass = i === currentPage ? ' roi-apu-page-active' : '';
html += `<button type="button" class="roi-apu-page-btn${activeClass}" data-page="${i}">${i}</button>`;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
html += `<span class="roi-apu-page-ellipsis">...</span>`;
}
html += `<button type="button" class="roi-apu-page-btn" data-page="${totalPages}">${totalPages}</button>`;
}
// Next button
if (currentPage < totalPages) {
html += `<button type="button" class="roi-apu-page-btn roi-apu-next" data-page="${currentPage + 1}">Siguiente &raquo;</button>`;
}
html += '</div>';
state.paginationEl.innerHTML = html;
state.paginationEl.style.display = 'flex';
// Add click handlers
state.paginationEl.querySelectorAll('.roi-apu-page-btn').forEach(btn => {
btn.addEventListener('click', () => {
const page = parseInt(btn.dataset.page, 10);
doSearch(state, page);
// Scroll to top of results
state.container.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
}
/**
* Highlight search term in text
*/
function highlightTerm(text, term) {
const escaped = escapeHtml(text);
const terms = term.split(/\s+/).filter(t => t.length >= 2);
let result = escaped;
terms.forEach(t => {
const regex = new RegExp(`(${escapeRegex(t)})`, 'gi');
result = result.replace(regex, '<mark>$1</mark>');
});
return result;
}
/**
* Format number with thousands separator
*/
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/**
* Format date
*/
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
/**
* Escape HTML entities
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Escape regex special characters
*/
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Show loading state
*/
function showLoading(state) {
state.loadingEl.style.display = 'flex';
state.button.disabled = true;
}
/**
* Hide loading state
*/
function hideLoading(state) {
state.loadingEl.style.display = 'none';
state.button.disabled = false;
}
/**
* Show error message
*/
function showError(state, message) {
state.errorEl.textContent = message;
state.errorEl.style.display = 'block';
}
/**
* Hide error message
*/
function hideError(state) {
state.errorEl.style.display = 'none';
}
/**
* Clear results
*/
function clearResults(state) {
state.resultsEl.innerHTML = '';
state.paginationEl.style.display = 'none';
state.infoEl.style.display = 'none';
state.totalResults = 0;
state.currentPage = 1;
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Expose for external use
window.ROI_APU_Search = {
init,
search: (containerId, term) => {
const state = instances.get(containerId);
if (state) {
state.input.value = term;
doSearch(state);
}
}
};
})();