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>
705 lines
24 KiB
JavaScript
705 lines
24 KiB
JavaScript
/**
|
|
* 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}">« 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 »</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);
|
|
}
|
|
}
|
|
};
|
|
|
|
})();
|