sw-fe/src/services/recoSearch.js

247 lines
7.0 KiB
JavaScript

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