commit 81d65c0f9aa2e4b0c24a6dfbbb8c1134b19d9f6d Author: FrankZamora Date: Wed Dec 3 10:59:56 2025 -0600 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 diff --git a/api/click-endpoint.php b/api/click-endpoint.php new file mode 100644 index 0000000..b5f1d9b --- /dev/null +++ b/api/click-endpoint.php @@ -0,0 +1,102 @@ +get_pdo(); + $prefix = $db->get_prefix(); + + // Build destination URL if empty + if (empty($dest)) { + // Obtain site_url from database (not HTTP_HOST) + $stmt = $pdo->prepare( + "SELECT option_value FROM {$prefix}options + WHERE option_name = 'home' LIMIT 1" + ); + $stmt->execute(); + $home = $stmt->fetch(PDO::FETCH_ASSOC); + $site_url = $home ? rtrim($home['option_value'], '/') : ''; + + // Try to get post_name for proper permalink + $stmt = $pdo->prepare( + "SELECT post_name FROM {$prefix}posts WHERE ID = ? LIMIT 1" + ); + $stmt->execute([$postId]); + $post = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($post && !empty($post['post_name'])) { + $dest = $site_url . '/' . $post['post_name'] . '/'; + } else { + $dest = $site_url . '/?p=' . $postId; + } + } + + // Get analytics config + $cfg = ROI_APU_Search_Analytics::get_config(); + + // Log click + ROI_APU_Search_Analytics::logClick( + $pdo, + $prefix, + $cfg, + $searchId, + $postId, + max(1, $position), + max(1, $page), + $dest + ); +} catch (\Throwable $e) { + error_log('ROI APU Search Click Error: ' . $e->getMessage()); + // Never break redirect due to tracking + // Fallback dest if error occurred before setting it + if (empty($dest)) { + $dest = '/?p=' . $postId; + } +} + +// Redirect to destination +header('Location: ' . $dest, true, 302); +exit; diff --git a/api/search-endpoint.php b/api/search-endpoint.php new file mode 100644 index 0000000..8473d3c --- /dev/null +++ b/api/search-endpoint.php @@ -0,0 +1,191 @@ + false, 'data' => ['message' => 'WordPress not found']]); + exit; + } +} + +require_once $wp_load; + +// Set JSON headers +header('Content-Type: application/json; charset=utf-8'); +header('Cache-Control: no-cache, must-revalidate'); + +// Only accept POST requests +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['success' => false, 'data' => ['message' => 'Method not allowed']]); + exit; +} + +// Verify nonce (with SHORTINIT, we need to load nonce functions manually) +// Since SHORTINIT doesn't load nonce functions, we'll implement basic validation +// sanitize_text_field() might not be available, use simple sanitization +$nonce = isset($_POST['nonce']) ? htmlspecialchars(strip_tags(trim($_POST['nonce'])), ENT_QUOTES, 'UTF-8') : ''; + +// Simple nonce validation using wp_hash +// In SHORTINIT mode, we have limited access to WP functions +// We'll verify based on a time-based token instead +if (empty($nonce)) { + http_response_code(403); + echo json_encode(['success' => false, 'data' => ['message' => 'Missing security token']]); + exit; +} + +// For SHORTINIT, we use a simplified token validation +// The token is generated with a salt and timestamp +$token_parts = explode('|', base64_decode($nonce)); +if (count($token_parts) !== 2) { + // Fallback: accept WP nonce format if available + // This allows compatibility when called from admin-ajax.php + // We'll be permissive here but log suspicious requests + error_log('ROI APU Search: Non-standard nonce format from ' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown')); +} + +// Get request parameters +$term = isset($_POST['term']) ? trim((string) $_POST['term']) : ''; +$page = isset($_POST['page']) ? max(1, (int) $_POST['page']) : 1; +$per_page = isset($_POST['per_page']) ? max(1, min(100, (int) $_POST['per_page'])) : 10; +$categories = isset($_POST['categories']) ? trim((string) $_POST['categories']) : ''; + +// Validate term +$min_len = 3; +$max_len = 250; + +if (mb_strlen($term, 'UTF-8') < $min_len) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'data' => ['message' => "Minimo {$min_len} caracteres"] + ]); + exit; +} + +if (mb_strlen($term, 'UTF-8') > $max_len) { + $term = mb_substr($term, 0, $max_len, 'UTF-8'); +} + +// Load plugin classes +$plugin_dir = dirname(__FILE__, 2); +require_once $plugin_dir . '/includes/class-db-connection.php'; +require_once $plugin_dir . '/includes/class-redis-cache.php'; +require_once $plugin_dir . '/includes/class-search-engine.php'; +require_once $plugin_dir . '/includes/class-analytics.php'; + +// Store raw term before any processing +$term_raw = isset($_POST['term']) ? (string) $_POST['term'] : ''; + +try { + // Get database connection + $db = ROI_APU_Search_DB::get_instance(); + $pdo = $db->get_pdo(); + $prefix = $db->get_prefix(); + + // Parse categories + $category_ids = []; + if (!empty($categories)) { + $parts = array_filter(array_map('trim', explode(',', $categories))); + foreach ($parts as $part) { + if (is_numeric($part)) { + $category_ids[] = (int) $part; + } + // Note: In SHORTINIT mode, we can't easily resolve slugs to IDs + // So we only accept numeric IDs in this fast endpoint + } + $category_ids = array_unique($category_ids); + } + + // Execute search + $search = new ROI_APU_Search_Engine($pdo); + $offset = ($page - 1) * $per_page; + $results = $search->run($term, $per_page, $offset, $category_ids); + + // In SHORTINIT mode, get_permalink() and home_url() won't work + // Obtain site_url and permalink_structure from database (not HTTP_HOST) + $stmt = $pdo->prepare( + "SELECT option_name, option_value FROM {$prefix}options + WHERE option_name IN ('home', 'permalink_structure')" + ); + $stmt->execute(); + $options = []; + while ($optRow = $stmt->fetch(PDO::FETCH_ASSOC)) { + $options[$optRow['option_name']] = $optRow['option_value']; + } + + $site_url = isset($options['home']) ? rtrim($options['home'], '/') : ''; + $permalink_structure = $options['permalink_structure'] ?? '/%postname%/'; + + // Build permalinks for each result using post_name + foreach ($results['rows'] as &$row) { + if (empty($row['permalink'])) { + $postName = $row['post_name'] ?? ''; + + // Fallback if post_name is empty + if (empty($postName)) { + $row['permalink'] = $site_url . '/?p=' . $row['ID']; + continue; + } + + // Build according to permalink structure + if (strpos($permalink_structure, '%post_id%') !== false) { + $row['permalink'] = $site_url . '/' . $row['ID'] . '/'; + } else { + $row['permalink'] = $site_url . '/' . $postName . '/'; + } + } + } + unset($row); + + // Log search for analytics + $analytics_cfg = ROI_APU_Search_Analytics::get_config(); + $search_id = ROI_APU_Search_Analytics::logSearch( + $pdo, + $prefix, + $analytics_cfg, + $term_raw, + $term, + $results, + $page, + $per_page + ); + + // Add search_id to response for click tracking + $results['search_id'] = $search_id; + + echo json_encode([ + 'success' => true, + 'data' => $results + ]); + +} catch (Exception $e) { + error_log('ROI APU Search Error: ' . $e->getMessage()); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'data' => ['message' => 'Error en la busqueda'] + ]); +} + +exit; diff --git a/assets/css/search-ui.css b/assets/css/search-ui.css new file mode 100644 index 0000000..c51556d --- /dev/null +++ b/assets/css/search-ui.css @@ -0,0 +1,414 @@ +/** + * ROI APU Search UI Styles + * + * @package ROI_APU_Search + */ + +/* Variables */ +:root { + --roi-apu-primary: #0d6efd; + --roi-apu-primary-hover: #0b5ed7; + --roi-apu-bg: #ffffff; + --roi-apu-bg-secondary: #f8f9fa; + --roi-apu-border: #dee2e6; + --roi-apu-text: #212529; + --roi-apu-text-muted: #6c757d; + --roi-apu-highlight: #fff3cd; + --roi-apu-error: #dc3545; + --roi-apu-success: #198754; + --roi-apu-radius: 8px; + --roi-apu-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + --roi-apu-transition: 0.2s ease; +} + +/* Container */ +.roi-apu-search-container { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 16px; + line-height: 1.5; + color: var(--roi-apu-text); + max-width: 100%; + margin: 0 auto; +} + +/* Search Form */ +.roi-apu-search-form { + margin-bottom: 1rem; +} + +.roi-apu-input-wrapper { + display: flex; + gap: 0.5rem; + background: var(--roi-apu-bg); + border: 2px solid var(--roi-apu-border); + border-radius: var(--roi-apu-radius); + padding: 0.25rem; + transition: border-color var(--roi-apu-transition), box-shadow var(--roi-apu-transition); +} + +.roi-apu-input-wrapper:focus-within { + border-color: var(--roi-apu-primary); + box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.15); +} + +.roi-apu-search-input { + flex: 1; + border: none; + padding: 0.75rem 1rem; + font-size: 1rem; + background: transparent; + outline: none; + min-width: 0; +} + +.roi-apu-search-input::placeholder { + color: var(--roi-apu-text-muted); +} + +.roi-apu-search-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: var(--roi-apu-primary); + color: white; + border: none; + border-radius: calc(var(--roi-apu-radius) - 4px); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background-color var(--roi-apu-transition); + white-space: nowrap; +} + +.roi-apu-search-btn:hover:not(:disabled) { + background: var(--roi-apu-primary-hover); +} + +.roi-apu-search-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.roi-apu-search-btn svg { + flex-shrink: 0; +} + +/* Error Message */ +.roi-apu-error { + margin-top: 0.5rem; + padding: 0.75rem 1rem; + background: #f8d7da; + color: var(--roi-apu-error); + border-radius: var(--roi-apu-radius); + font-size: 0.875rem; +} + +/* Loading */ +.roi-apu-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2rem; + color: var(--roi-apu-text-muted); +} + +.roi-apu-spinner { + width: 24px; + height: 24px; + border: 3px solid var(--roi-apu-border); + border-top-color: var(--roi-apu-primary); + border-radius: 50%; + animation: roi-apu-spin 0.8s linear infinite; +} + +@keyframes roi-apu-spin { + to { transform: rotate(360deg); } +} + +/* Info Bar */ +.roi-apu-info { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: var(--roi-apu-bg-secondary); + border-radius: var(--roi-apu-radius); + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.roi-apu-info-total { + font-weight: 600; +} + +.roi-apu-info-time { + color: var(--roi-apu-text-muted); +} + +/* Results */ +.roi-apu-results-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.roi-apu-result-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: var(--roi-apu-bg); + border: 1px solid var(--roi-apu-border); + border-radius: var(--roi-apu-radius); + text-decoration: none; + color: inherit; + transition: border-color var(--roi-apu-transition), box-shadow var(--roi-apu-transition); +} + +.roi-apu-result-item:hover { + border-color: var(--roi-apu-primary); + box-shadow: var(--roi-apu-shadow); +} + +.roi-apu-result-position { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--roi-apu-bg-secondary); + border-radius: 50%; + font-size: 0.75rem; + font-weight: 600; + color: var(--roi-apu-text-muted); + flex-shrink: 0; +} + +.roi-apu-result-content { + flex: 1; + min-width: 0; +} + +.roi-apu-result-title { + margin: 0 0 0.25rem; + font-size: 1rem; + font-weight: 500; + line-height: 1.4; + color: var(--roi-apu-text); +} + +.roi-apu-result-title mark { + background: var(--roi-apu-highlight); + padding: 0 2px; + border-radius: 2px; +} + +.roi-apu-result-date { + font-size: 0.8125rem; + color: var(--roi-apu-text-muted); +} + +.roi-apu-result-arrow { + flex-shrink: 0; + color: var(--roi-apu-text-muted); + transition: transform var(--roi-apu-transition), color var(--roi-apu-transition); +} + +.roi-apu-result-item:hover .roi-apu-result-arrow { + transform: translateX(4px); + color: var(--roi-apu-primary); +} + +/* No Results */ +.roi-apu-no-results { + text-align: center; + padding: 3rem 2rem; + background: var(--roi-apu-bg-secondary); + border-radius: var(--roi-apu-radius); +} + +.roi-apu-no-results p { + margin: 0; +} + +.roi-apu-no-results p:first-child { + font-size: 1.125rem; + margin-bottom: 0.5rem; +} + +.roi-apu-suggestions { + color: var(--roi-apu-text-muted); + font-size: 0.875rem; +} + +/* Pagination */ +.roi-apu-pagination { + display: flex; + justify-content: center; + margin-top: 1.5rem; +} + +.roi-apu-pagination-inner { + display: flex; + align-items: center; + gap: 0.25rem; + flex-wrap: wrap; + justify-content: center; +} + +.roi-apu-page-btn { + display: flex; + align-items: center; + justify-content: center; + min-width: 40px; + height: 40px; + padding: 0 0.75rem; + background: var(--roi-apu-bg); + border: 1px solid var(--roi-apu-border); + border-radius: var(--roi-apu-radius); + font-size: 0.875rem; + cursor: pointer; + transition: all var(--roi-apu-transition); +} + +.roi-apu-page-btn:hover:not(.roi-apu-page-active) { + background: var(--roi-apu-bg-secondary); + border-color: var(--roi-apu-primary); +} + +.roi-apu-page-btn.roi-apu-page-active { + background: var(--roi-apu-primary); + border-color: var(--roi-apu-primary); + color: white; +} + +.roi-apu-page-btn.roi-apu-prev, +.roi-apu-page-btn.roi-apu-next { + padding: 0 1rem; +} + +.roi-apu-page-ellipsis { + padding: 0 0.5rem; + color: var(--roi-apu-text-muted); +} + +/* Responsive */ +@media (max-width: 640px) { + .roi-apu-input-wrapper { + flex-direction: column; + } + + .roi-apu-search-btn { + width: 100%; + justify-content: center; + } + + .roi-apu-btn-text { + display: inline; + } + + .roi-apu-result-item { + padding: 0.875rem; + } + + .roi-apu-result-position { + display: none; + } + + .roi-apu-info { + flex-direction: column; + gap: 0.25rem; + text-align: center; + } + + .roi-apu-page-btn.roi-apu-prev, + .roi-apu-page-btn.roi-apu-next { + padding: 0 0.5rem; + font-size: 0.8125rem; + } +} + +/* Hide text on very small screens */ +@media (max-width: 480px) { + .roi-apu-btn-text { + display: none; + } + + .roi-apu-search-btn { + padding: 0.75rem; + } +} + +/* ================================================================= + Anuncios en resultados de busqueda + Usa variables CSS del tema para consistencia + VISIBLES por defecto para que AdSense pueda medirlos y llenarlos + Se ocultan via JS si no se llenan despues del timeout + ================================================================= */ + +.roi-apu-ad-item { + /* VISIBLE por defecto - AdSense necesita medir el elemento */ + display: block; + + /* Spacing usando variables del tema o fallbacks */ + padding: var(--roi-spacing-md, 1rem); + margin: var(--roi-spacing-sm, 0.5rem) 0; + + /* Fondo sutil que no compita con resultados */ + background: var(--roi-bg-light, #f8f9fa); + border-radius: var(--roi-radius-sm, 0.375rem); + + /* Centrar contenido del anuncio */ + text-align: center; + + /* Borde sutil para separar del contenido */ + border: 1px solid var(--roi-border-light, rgba(0,0,0,0.05)); + + /* Altura minima para que AdSense pueda renderizar */ + min-height: 100px; +} + +/* Ocultar ads que no se llenaron (clase agregada via JS) */ +.roi-apu-ad-item.ad-unfilled { + display: none; +} + +.roi-apu-ad-item ins.adsbygoogle { + display: block !important; + min-height: 100px; /* Altura minima para evitar CLS */ +} + +/* Anuncio superior - mas prominente */ +.roi-apu-ad-item[data-ad-position="top"] { + margin-top: 0; + margin-bottom: var(--roi-spacing-md, 1rem); + background: var(--roi-bg-muted, #f1f3f4); +} + +/* Anuncios entre resultados - menos intrusivos */ +.roi-apu-ad-item:not([data-ad-position="top"]) { + margin: var(--roi-spacing-md, 1rem) 0; +} + +/* Responsive: En movil, padding reducido */ +@media (max-width: 768px) { + .roi-apu-ad-item { + padding: var(--roi-spacing-sm, 0.5rem); + } + + .roi-apu-ad-item ins.adsbygoogle { + min-height: 80px; + } +} + +/* ================================================================= + Dark Mode Support + El input siempre tiene fondo blanco, forzar texto oscuro + ================================================================= */ +@media (prefers-color-scheme: dark) { + .roi-apu-search-input { + color: var(--roi-apu-text); + } +} diff --git a/assets/js/search-handler.js b/assets/js/search-handler.js new file mode 100644 index 0000000..164cbc7 --- /dev/null +++ b/assets/js/search-handler.js @@ -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 '
' + + '' + + '