/** * recoSearch.js * * BUT: Optionnel. * - Si tu veux que le frontend fasse une "suggestion/recherche" via Rico, * FAIS-LE via le core-api (BFF), pas direct vers Rico. * * IMPORTANT: * - Si tu n'importes pas ce fichier, rien ne change dans ton UI. * - Ton search bar actuelle filtre déjà localement (0 call réseau). */ function apiPrefix() { const raw = (import.meta?.env?.VITE_API_BASE_URL || "") .toString() .replace(/\/+$/, ""); if (raw) return raw + "/api"; try { if (typeof window !== "undefined") { const host = window.location.hostname || ""; if (host.startsWith("www.")) { return `https://${host.replace(/^www\./, "")}/api`; } } } catch {} return "/api"; } const DEFAULT_PATHS = [ "/suggestions", // FAST: Meilisearch autocomplete (cities, posts, profiles) - PRIMARY "/unified-search", // Fallback: Unified search (posts + profiles + places) "/search", // Fallback: Posts only "/reco/search", "/reco/suggest", "/recommendations/search", ]; const searchCache = new Map(); function cacheGet(query) { const key = query.toLowerCase().trim(); const hit = searchCache.get(key); if (!hit) return null; if (hit.expiresAt <= Date.now()) { searchCache.delete(key); return null; } return hit.value; } function cacheSet(query, value, ttlMs) { const key = query.toLowerCase().trim(); if (!key) return; searchCache.set(key, { value, expiresAt: Date.now() + ttlMs }); } /** * Normalise un item venant du core-api. * On reste permissif: Rico / core-api peut renvoyer plusieurs shapes. */ function normalizeOne(r) { if (!r || typeof r !== "object") return null; const title = (r.title ?? r.name ?? r.label ?? r.display_name ?? r.disp ?? r.text ?? "") .toString() .trim(); // coords possibles (support multiple formats) const lat = typeof r.lat === "number" ? r.lat : (typeof r.latitude === "number" ? r.latitude : null); const lon = typeof r.lon === "number" ? r.lon : (typeof r.lng === "number" ? r.lng : null); const coords = Array.isArray(r.coords) && r.coords.length === 2 ? r.coords : (lat != null && lon != null ? [lon, lat] : null); const type = (r.type ?? r.kind ?? r.entity ?? "").toString().toLowerCase().trim(); const hasPostSignal = !!(r.category || r.sub_category || r.subCategory || r.author || r.snippet || r.body || r.content || r.url || r.cluster_key || r.clusterKey || r.template || r.source); const kind = type || (hasPostSignal ? "post" : coords ? "place" : "item"); const tags = Array.isArray(r.event_tags) ? r.event_tags : Array.isArray(r.tags) ? r.tags : []; const postID = typeof r.post_id === "number" && r.post_id > 0 ? r.post_id : typeof r.postId === "number" && r.postId > 0 ? r.postId : null; return { id: (postID ?? r.id ?? r._id ?? r.key ?? title).toString(), type: kind, kind: kind, // Keep both for compatibility title, subtitle: (r.subtitle ?? r.sub ?? r.extra ?? r.category ?? "").toString(), coords, lat, lon, tags, post_id: postID, raw: r, }; } /** * Tente une recherche sur une liste de paths (au cas où le endpoint exact diffère). * Retourne [] sur 404/erreur. */ async function tryFetchAnyPath(q, { signal, limit } = {}) { const query = (q || "").toString().trim(); const isEmptyQuery = query === ""; const maxResults = limit && limit > 0 ? limit : 12; const cached = cacheGet(query); if (cached) return cached; // Permet override runtime si tu veux: // window.__SW_RECO_SEARCH_PATH = "/api/reco/search" const override = typeof window !== "undefined" && window.__SW_RECO_SEARCH_PATH ? String(window.__SW_RECO_SEARCH_PATH) : ""; const prefix = apiPrefix(); const basePaths = isEmptyQuery ? ["/suggestions"] : DEFAULT_PATHS; const paths = override ? [override, ...basePaths] : basePaths; const posts = []; const locations = []; const profiles = []; const seen = new Set(); const seenPostIDs = new Set(); const minLocationResults = Math.min(2, maxResults); const locationKinds = new Set([ "place", "city", "region", "province", "state", "country", "area", "district", "poi", "venue", "location", ]); for (const p of paths) { try { const base = p.startsWith("http") ? p : `${prefix}${p}`; const url = `${base}?q=${encodeURIComponent(query)}`; const res = await fetch(url, { credentials: "include", signal }); if (res.status === 404) { continue; // Try next path } if (!res.ok) { return []; // Avoid spam errors } const data = await res.json().catch(() => null); const arr = Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : Array.isArray(data?.results) ? data.results : []; if (arr.length === 0) { continue; // Try next path } const normalized = arr.map(normalizeOne).filter(Boolean); if (normalized.length === 0) { continue; // Try next path } for (const it of normalized) { const rawPostID = typeof it.post_id === "number" && it.post_id > 0 ? it.post_id : typeof it.raw?.post_id === "number" && it.raw.post_id > 0 ? it.raw.post_id : typeof it.raw?.postId === "number" && it.raw.postId > 0 ? it.raw.postId : null; if (rawPostID && seenPostIDs.has(rawPostID)) continue; if (seen.has(it.id)) continue; seen.add(it.id); if (rawPostID) seenPostIDs.add(rawPostID); const kind = (it.kind || "").toLowerCase(); if (locationKinds.has(kind)) { locations.push(it); } else if (kind === "profile") { profiles.push(it); } else { posts.push(it); } } const totalCollected = posts.length + locations.length + profiles.length; if (totalCollected >= maxResults && locations.length >= minLocationResults) { break; // Collected enough results } } catch (e) { // Abort => stop network calls if (e && (e.name === "AbortError" || e.code === 20)) { return []; } // Other errors => try next path continue; } } const totalCollected = posts.length + locations.length + profiles.length; if (totalCollected === 0) { return []; } const locationSlots = Math.min(minLocationResults, locations.length); const initialResults = [ ...locations.slice(0, locationSlots), ...posts, ...locations.slice(locationSlots), ...profiles, ]; const resultSlice = initialResults.slice(0, maxResults); cacheSet(query, resultSlice, 30 * 1000); return resultSlice; } /** * recoSearch(query): renvoie une liste normalisée. * - "safe" : retourne [] si rien marche. */ export async function recoSearch(query, opts = {}) { return tryFetchAnyPath(query, opts); }