diff --git a/src/api/client.js b/src/api/client.js index 26c1271..63cfe28 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -4,7 +4,22 @@ import { loadAuth } from "../auth/authStorage"; -const API_BASE = "/api"; +function apiBase() { + 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"; +} function authHeaders() { try { @@ -17,6 +32,45 @@ function authHeaders() { } } +const responseCache = new Map(); + +function cacheKeyFor(url, headers = {}) { + const auth = headers?.Authorization || headers?.authorization; + if (auth) return ""; // avoid caching authenticated responses + return `GET:${url}`; +} + +function cloneJson(value) { + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return value; + } +} + +async function fetchJsonCached(url, { ttlMs = 0, headers = {}, credentials = "include" } = {}) { + const key = cacheKeyFor(url, headers); + if (ttlMs > 0 && key) { + const hit = responseCache.get(key); + if (hit && hit.expiresAt > Date.now()) { + return cloneJson(hit.value); + } + } + + const res = await fetch(url, { credentials, headers }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + const err = new Error(text || `HTTP ${res.status}`); + err.status = res.status; + throw err; + } + const data = await res.json().catch(() => null); + if (ttlMs > 0 && key) { + responseCache.set(key, { value: data, expiresAt: Date.now() + ttlMs }); + } + return cloneJson(data); +} + function isAssetRef(value) { return typeof value === "string" && value.startsWith("asset://"); @@ -27,7 +81,7 @@ async function resolveAssetURLs(ids) { const qs = new URLSearchParams(); qs.set("ids", ids.join(",")); try { - const res = await fetch(`${API_BASE}/assets/resolve?${qs.toString()}`, { + const res = await fetch(`${apiBase()}/assets/resolve?${qs.toString()}`, { credentials: "include", headers: { ...authHeaders() }, }); @@ -115,20 +169,16 @@ export async function fetchPosts(filters = {}) { } const qs = params.toString(); - const url = qs ? `${API_BASE}/posts?${qs}` : `${API_BASE}/posts`; + const url = qs ? `${apiBase()}/posts?${qs}` : `${apiBase()}/posts`; - const res = await fetch(url, { - credentials: "include", - }); - - if (!res.ok) { - console.warn("fetchPosts failed (treated as empty)", res.status); + try { + const data = await fetchJsonCached(url, { ttlMs: 15000 }); + const items = Array.isArray(data) ? data : data?.items || []; + return await resolveAssetRefsInPosts(items); + } catch (err) { + console.warn("fetchPosts failed (treated as empty)", err?.status || err); return []; } - - const data = await res.json(); - const items = Array.isArray(data) ? data : data.items || []; - return await resolveAssetRefsInPosts(items); } /** @@ -136,27 +186,8 @@ export async function fetchPosts(filters = {}) { */ export async function fetchIpLocation() { try { - const res = await fetch(`${API_BASE}/ip-location`, { - credentials: "include", - }); - - if (!res.ok) { - console.warn("fetchIpLocation: HTTP", res.status); - return null; - } - - const text = await res.text(); - if (!text || text === "null") { - return null; - } - - let data; - try { - data = JSON.parse(text); - } catch (e) { - console.warn("fetchIpLocation: JSON parse error", e, text); - return null; - } + const data = await fetchJsonCached(`${apiBase()}/ip-location`, { ttlMs: 10 * 60 * 1000 }); + if (!data || data === "null") return null; const lat = data.lat; const lon = data.lon; @@ -190,7 +221,7 @@ export async function createPost(payload, token) { headers["Authorization"] = `Bearer ${token}`; } - const res = await fetch(`${API_BASE}/post`, { + const res = await fetch(`${apiBase()}/post`, { method: "POST", headers, credentials: "include", @@ -235,7 +266,7 @@ export async function fetchPostPreview(url, token) { if (token) headers["Authorization"] = `Bearer ${token}`; else Object.assign(headers, authHeaders()); - const res = await fetch(`${API_BASE}/post/preview`, { + const res = await fetch(`${apiBase()}/post/preview`, { method: "POST", headers, credentials: "include", @@ -260,11 +291,7 @@ export async function fetchPostById(postId) { const qs = new URLSearchParams(); qs.set("id", String(postId)); try { - const res = await fetch(`${API_BASE}/post?${qs.toString()}`, { - credentials: "include", - }); - if (!res.ok) return null; - const data = await res.json().catch(() => null); + const data = await fetchJsonCached(`${apiBase()}/post?${qs.toString()}`, { ttlMs: 15000 }); if (!data || typeof data !== "object") return null; const resolved = await resolveAssetRefsInPosts([data]); return resolved && resolved[0] ? resolved[0] : data; @@ -279,11 +306,7 @@ export async function fetchPostByUrl(url) { const qs = new URLSearchParams(); qs.set("url", raw); try { - const res = await fetch(`${API_BASE}/post?${qs.toString()}`, { - credentials: "include", - }); - if (!res.ok) return null; - const data = await res.json().catch(() => null); + const data = await fetchJsonCached(`${apiBase()}/post?${qs.toString()}`, { ttlMs: 15000 }); if (!data || typeof data !== "object") return null; const resolved = await resolveAssetRefsInPosts([data]); return resolved && resolved[0] ? resolved[0] : data; @@ -297,7 +320,7 @@ export async function fetchPostTruth(postId) { const qs = new URLSearchParams(); qs.set("post_id", String(postId)); try { - const res = await fetch(`${API_BASE}/post/truth?${qs.toString()}`, { + const res = await fetch(`${apiBase()}/post/truth?${qs.toString()}`, { credentials: "include", headers: { ...authHeaders() }, }); @@ -312,7 +335,7 @@ export async function fetchPostTruth(postId) { export async function votePostTruth(postId, vote) { if (!postId) throw new Error("post_id required"); const body = JSON.stringify({ post_id: postId, vote }); - const res = await fetch(`${API_BASE}/post/truth-vote`, { + const res = await fetch(`${apiBase()}/post/truth-vote`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, credentials: "include", @@ -331,7 +354,7 @@ export async function updatePostVisibility(postId, payload = {}, token) { const headers = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; const body = JSON.stringify({ post_id: postId, ...payload }); - const res = await fetch(`${API_BASE}/post/visibility`, { + const res = await fetch(`${apiBase()}/post/visibility`, { method: "PATCH", headers, credentials: "include", @@ -352,7 +375,7 @@ export async function updatePostVisibility(postId, payload = {}, token) { * payload: { username, email, password, firstName, lastName } */ export async function registerUser(payload) { - const res = await fetch(`${API_BASE}/signup`, { + const res = await fetch(`${apiBase()}/signup`, { method: "POST", headers: { "Content-Type": "application/json", @@ -397,15 +420,10 @@ export async function fetchSmartFeed(params = {}) { if (maxLon != null) qs.set("maxLon", String(maxLon)); } - const url = `${API_BASE}/smart-feed?${qs.toString()}`; + const url = `${apiBase()}/smart-feed?${qs.toString()}`; try { - const res = await fetch(url, { credentials: "include" }); - if (!res.ok) { - console.warn("fetchSmartFeed failed", res.status); - return []; - } - const data = await res.json(); + const data = await fetchJsonCached(url, { ttlMs: 10000 }); const items = Array.isArray(data) ? data : data.results || data.items || []; return await resolveAssetRefsInPosts(items); } catch (err) { @@ -428,15 +446,10 @@ export async function fetchUnifiedSearch(query, params = {}) { if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km)); if (typeof params.limit === "number") qs.set("limit", String(params.limit)); - const url = `${API_BASE}/unified-search?${qs.toString()}`; + const url = `${apiBase()}/unified-search?${qs.toString()}`; try { - const res = await fetch(url, { credentials: "include" }); - if (!res.ok) { - console.warn("fetchUnifiedSearch failed", res.status); - return { posts: [], profiles: [], places: [] }; - } - const data = await res.json(); + const data = await fetchJsonCached(url, { ttlMs: 15000 }); if (data && typeof data === "object" && !Array.isArray(data)) { if (Array.isArray(data.posts)) { data.posts = await resolveAssetRefsInPosts(data.posts); @@ -464,7 +477,7 @@ export async function pollNewPosts(params = {}) { if (params.category) qs.set("category", params.category); if (typeof params.limit === "number") qs.set("limit", String(params.limit)); - const url = `${API_BASE}/poll?${qs.toString()}`; + const url = `${apiBase()}/poll?${qs.toString()}`; try { const res = await fetch(url, { credentials: "include" }); @@ -498,7 +511,7 @@ export async function fetchIslands(params = {}) { if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km)); if (typeof params.limit === "number") qs.set("limit", String(params.limit)); - const url = `${API_BASE}/islands/nearby?${qs.toString()}`; + const url = `${apiBase()}/islands/nearby?${qs.toString()}`; try { const res = await fetch(url, { credentials: "include" }); @@ -523,7 +536,7 @@ export async function fetchIslands(params = {}) { */ export async function fetchIsland(islandId) { if (!islandId) return null; - const url = `${API_BASE}/islands/${encodeURIComponent(islandId)}`; + const url = `${apiBase()}/islands/${encodeURIComponent(islandId)}`; try { const res = await fetch(url, { credentials: "include" }); @@ -545,7 +558,7 @@ export async function fetchIsland(islandId) { export async function fetchIslandByUsername(username) { if (!username) return null; - const url = `${API_BASE}/islands/by-username?username=${encodeURIComponent(username)}`; + const url = `${apiBase()}/islands/by-username?username=${encodeURIComponent(username)}`; try { const res = await fetch(url, { credentials: "include" }); if (!res.ok) { @@ -566,7 +579,7 @@ export async function fetchIslandByUsername(username) { export async function uploadProfileAvatar(file, opts = {}) { if (!file) return { ok: false }; - const url = `${API_BASE}/profile/avatar`; + const url = `${apiBase()}/profile/avatar`; const form = new FormData(); form.append("file", file); if (opts.visibility) form.append("visibility", opts.visibility); @@ -595,7 +608,7 @@ export async function uploadProfileAvatar(file, opts = {}) { } export async function updateProfileUsername(username) { - const url = `${API_BASE}/profile/username`; + const url = `${apiBase()}/profile/username`; try { const res = await fetch(url, { method: "POST", @@ -620,7 +633,7 @@ export async function updateProfileUsername(username) { export async function fetchProfile(username = "") { const qs = new URLSearchParams(); if (username) qs.set("username", username); - const url = qs.toString() ? `${API_BASE}/profile?${qs}` : `${API_BASE}/profile`; + const url = qs.toString() ? `${apiBase()}/profile?${qs}` : `${apiBase()}/profile`; try { const res = await fetch(url, { credentials: "include", @@ -643,7 +656,7 @@ export async function fetchProfile(username = "") { export async function recordPostView(postId) { if (!postId) return { ok: false }; try { - const res = await fetch(`${API_BASE}/post/view`, { + const res = await fetch(`${apiBase()}/post/view`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", @@ -659,7 +672,7 @@ export async function recordPostView(postId) { export async function togglePostLike(postId, like = true) { if (!postId) return { ok: false }; try { - const res = await fetch(`${API_BASE}/post/like`, { + const res = await fetch(`${apiBase()}/post/like`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, credentials: "include", @@ -675,7 +688,7 @@ export async function togglePostLike(postId, like = true) { export async function recordPostShare(postId) { if (!postId) return { ok: false }; try { - const res = await fetch(`${API_BASE}/post/share`, { + const res = await fetch(`${apiBase()}/post/share`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", @@ -694,7 +707,7 @@ export async function fetchPostComments(postId, limit = 20) { qs.set("post_id", String(postId)); if (limit) qs.set("limit", String(limit)); try { - const res = await fetch(`${API_BASE}/post/comments?${qs.toString()}`, { + const res = await fetch(`${apiBase()}/post/comments?${qs.toString()}`, { credentials: "include", }); if (!res.ok) return []; @@ -708,7 +721,7 @@ export async function fetchPostComments(postId, limit = 20) { export async function createPostComment(postId, body) { if (!postId || !body) return { ok: false }; try { - const res = await fetch(`${API_BASE}/post/comment`, { + const res = await fetch(`${apiBase()}/post/comment`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, credentials: "include", @@ -726,7 +739,7 @@ export async function fetchPostLikeState(postId) { const qs = new URLSearchParams(); qs.set("post_id", String(postId)); try { - const res = await fetch(`${API_BASE}/post/like-state?${qs.toString()}`, { + const res = await fetch(`${apiBase()}/post/like-state?${qs.toString()}`, { credentials: "include", headers: { ...authHeaders() }, }); diff --git a/src/services/recoSearch.js b/src/services/recoSearch.js index 0515718..0fad7da 100644 --- a/src/services/recoSearch.js +++ b/src/services/recoSearch.js @@ -10,15 +10,51 @@ * - 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 = [ - "/api/suggestions", // NEW: Autocomplete suggestions (cities, posts, profiles) - "/api/unified-search", // Unified search (posts + profiles + places) - "/api/search", // Fallback: Posts only - "/api/reco/search", - "/api/reco/suggest", - "/api/recommendations/search", + "/suggestions", // NEW: Autocomplete suggestions (cities, posts, profiles) + "/unified-search", // 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. @@ -70,6 +106,8 @@ function normalizeOne(r) { async function tryFetchAnyPath(q, { signal } = {}) { const query = (q || "").toString().trim(); if (!query) return []; + const cached = cacheGet(query); + if (cached) return cached; // Permet override runtime si tu veux: // window.__SW_RECO_SEARCH_PATH = "/api/reco/search" @@ -78,11 +116,13 @@ async function tryFetchAnyPath(q, { signal } = {}) { ? String(window.__SW_RECO_SEARCH_PATH) : ""; + const prefix = apiPrefix(); const paths = override ? [override, ...DEFAULT_PATHS] : DEFAULT_PATHS; for (const p of paths) { try { - const url = `${p}?q=${encodeURIComponent(query)}`; + const base = p.startsWith("http") ? p : `${prefix}${p}`; + const url = `${base}?q=${encodeURIComponent(query)}`; console.log('[recoSearch] Trying:', url); const res = await fetch(url, { credentials: "include", signal }); @@ -106,7 +146,9 @@ async function tryFetchAnyPath(q, { signal } = {}) { : []; console.log('[recoSearch] Success! Returning', arr.length, 'results from', p); - return arr.map(normalizeOne).filter(Boolean); + const out = arr.map(normalizeOne).filter(Boolean); + cacheSet(query, out, 30 * 1000); + return out; } catch (e) { // Abort => stop net if (e && (e.name === "AbortError" || e.code === 20)) {