sw-fe/src/components/Search/SmartSearchBar.jsx

237 lines
7.2 KiB
JavaScript

import React, { useEffect, useMemo, useRef, useState } from "react";
import { recoSearch } from "../../services/recoSearch";
import "../../styles/smartSearchBar.css";
function defaultZoomForKind(kind) {
const k = (kind || "").toLowerCase();
// Specific types from suggestions endpoint
if (k === "post") return 16; // Close zoom for posts
if (k === "profile") return 14; // Medium zoom for profiles
if (k === "city") return 11; // City-level zoom
if (k === "region") return 7; // Wide zoom for regions/provinces
if (k === "place" || k === "poi") return 13; // Places zoom
// Legacy/fallback
if (k.includes("post")) return 15;
if (k.includes("city")) return 11;
if (k.includes("region") || k.includes("area") || k.includes("province") || k.includes("state")) return 7;
if (k.includes("place")) return 13;
return 12; // Default
}
function IconPin(props) {
return (
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" {...props}>
<path
d="M12 22s7-6.1 7-13a7 7 0 1 0-14 0c0 6.9 7 13 7 13zm0-9.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7z"
fill="currentColor"
/>
</svg>
);
}
function IconWaves(props) {
return (
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" {...props}>
<path
d="M3 10c2 0 2-2 4-2s2 2 4 2 2-2 4-2 2 2 4 2 2-2 4-2v2c-2 0-2 2-4 2s-2-2-4-2-2 2-4 2-2-2-4-2-2 2-4 2V10z"
fill="currentColor"
/>
<path
d="M3 14c2 0 2-2 4-2s2 2 4 2 2-2 4-2 2 2 4 2 2-2 4-2v2c-2 0-2 2-4 2s-2-2-4-2-2 2-4 2-2-2-4-2-2 2-4 2v-2z"
fill="currentColor"
opacity="0.75"
/>
</svg>
);
}
/**
* Props:
* - placeholder
* - onPick(item, { zoom, reason }) // call when user selects suggestion
* - onSearch(query) // call when user presses Enter to filter/search
* - onMySpot() // keep same function (center on user)
* - onPlaceTourWire() // keep same function (place/tour/wire)
*/
export default function SmartSearchBar({
placeholder = "Search places, regions, posts…",
onPick,
onSearch,
onMySpot,
onPlaceTourWire,
}) {
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [items, setItems] = useState([]);
const [active, setActive] = useState(-1);
const [err, setErr] = useState("");
const [hasSearched, setHasSearched] = useState(false);
const abortRef = useRef(null);
const boxRef = useRef(null);
useEffect(() => {
function onDocDown(e) {
if (!boxRef.current) return;
if (!boxRef.current.contains(e.target)) setOpen(false);
}
document.addEventListener("mousedown", onDocDown);
return () => document.removeEventListener("mousedown", onDocDown);
}, []);
// Fonction pour déclencher une recherche
const triggerSearch = async (searchQuery) => {
if (abortRef.current) abortRef.current.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setErr("");
try {
console.log('[SmartSearchBar] Searching for:', searchQuery);
const res = await recoSearch(searchQuery, { signal: ctrl.signal, limit: 12 });
console.log('[SmartSearchBar] Got results:', res?.length || 0, res);
setItems(res);
setOpen(true);
setActive(res.length ? 0 : -1);
setHasSearched(true);
} catch (e) {
if (ctrl.signal.aborted) return;
console.error('[SmartSearchBar] Search error:', e);
setErr((e && e.message) ? e.message : "Search error");
setItems([]);
} finally {
if (!ctrl.signal.aborted) setLoading(false);
}
};
useEffect(() => {
const query = q.trim();
if (abortRef.current) abortRef.current.abort();
const t = setTimeout(() => {
triggerSearch(query);
}, 150); // Debounce 150ms
return () => {
clearTimeout(t);
if (abortRef.current) abortRef.current.abort();
};
}, [q]);
const showList = open && (items.length > 0 || loading || err);
const pick = (it, reason = "click") => {
if (!it) return;
const zoom = defaultZoomForKind(it.kind);
setOpen(false);
setQ(it.title || q);
onPick && onPick(it, { zoom, reason });
};
const onKeyDown = (e) => {
if (!showList && (e.key === "ArrowDown" || e.key === "Enter")) setOpen(true);
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((i) => Math.min(i + 1, items.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (items.length && active >= 0) {
// Pick the selected suggestion
pick(items[active], "enter");
} else if (q.trim()) {
// No item selected, trigger search/filter
setOpen(false);
onSearch && onSearch(q.trim());
}
} else if (e.key === "Escape") {
setOpen(false);
}
};
const rightTitle = useMemo(() => {
// visuel flexible, même fonction: tu peux renommer plus tard
return "Place / Tour / Wire";
}, []);
return (
<div className="sw-search" ref={boxRef}>
<div className="sw-search__bar">
<input
className="sw-search__input"
value={q}
onChange={(e) => setQ(e.target.value)}
onFocus={() => {
setOpen(true);
// Si pas encore cherché et query vide, charger suggestions populaires
if (!hasSearched && !q.trim()) {
triggerSearch("");
}
}}
onKeyDown={onKeyDown}
placeholder={placeholder}
aria-label="Search"
/>
<div className="sw-search__icons" aria-label="Search actions">
<button
type="button"
className="sw-search__iconBtn"
title={rightTitle}
onClick={() => onPlaceTourWire && onPlaceTourWire()}
>
<IconWaves />
</button>
<button
type="button"
className="sw-search__iconBtn"
title="My spot"
onClick={() => onMySpot && onMySpot()}
>
<IconPin />
</button>
</div>
</div>
{showList && (
<div className="sw-search__dropdown">
{loading && <div className="sw-search__row sw-search__muted">Searching</div>}
{err && <div className="sw-search__row sw-search__error">{err}</div>}
{!loading && !err && items.map((it, idx) => (
<button
type="button"
key={it.id}
className={"sw-search__row sw-search__item" + (idx === active ? " is-active" : "")}
onMouseEnter={() => setActive(idx)}
onClick={() => pick(it, "click")}
>
<div className="sw-search__title">{it.title || "Untitled"}</div>
<div className="sw-search__sub">
{(it.kind ? it.kind : "result")}
{it.subtitle ? " • " + it.subtitle : ""}
</div>
</button>
))}
{!loading && !err && items.length === 0 && (
<div className="sw-search__row sw-search__muted">No results</div>
)}
</div>
)}
</div>
);
}