247 lines
7.0 KiB
JavaScript
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);
|
|
}
|