276 lines
8.2 KiB
JavaScript
276 lines
8.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 5; // 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 5;
|
|
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 IconPlus(props) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" {...props}>
|
|
<path
|
|
d="M12 5v14m-7-7h14"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function truncateText(value, maxLen) {
|
|
const s = (value || "").toString();
|
|
if (!maxLen || s.length <= maxLen) return s;
|
|
return s.slice(0, Math.max(0, maxLen - 1)) + "…";
|
|
}
|
|
|
|
/**
|
|
* 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 skipSearchRef = useRef(false);
|
|
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 (skipSearchRef.current) {
|
|
skipSearchRef.current = false;
|
|
return;
|
|
}
|
|
|
|
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);
|
|
skipSearchRef.current = true;
|
|
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
|
|
skipSearchRef.current = true;
|
|
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";
|
|
}, []);
|
|
|
|
const handleClear = () => {
|
|
setQ("");
|
|
setItems([]);
|
|
setOpen(false);
|
|
onSearch && onSearch(""); // Clear filters
|
|
};
|
|
|
|
return (
|
|
<div className="sw-search" ref={boxRef}>
|
|
<div className="sw-search__bar">
|
|
<input
|
|
className="sw-search__input"
|
|
value={q}
|
|
onChange={(e) => {
|
|
const newValue = e.target.value;
|
|
setQ(newValue);
|
|
// Si on efface tout le texte, clear les filtres
|
|
if (!newValue.trim() && q.trim()) {
|
|
onSearch && onSearch("");
|
|
}
|
|
}}
|
|
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"
|
|
/>
|
|
|
|
{q && (
|
|
<button
|
|
type="button"
|
|
className="sw-search__clearBtn"
|
|
title="Clear search"
|
|
onClick={handleClear}
|
|
aria-label="Clear search"
|
|
>
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M18 6L6 18M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
<div className="sw-search__icons" aria-label="Search actions">
|
|
<button
|
|
type="button"
|
|
className="sw-search__iconBtn"
|
|
title={rightTitle}
|
|
onClick={() => onPlaceTourWire && onPlaceTourWire()}
|
|
>
|
|
<IconPlus />
|
|
</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">{truncateText(it.title || "Untitled", 140)}</div>
|
|
<div className="sw-search__sub">
|
|
{truncateText((it.kind ? it.kind : "result") + (it.subtitle ? " • " + it.subtitle : ""), 120)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
|
|
{!loading && !err && items.length === 0 && (
|
|
<div className="sw-search__row sw-search__muted">No results</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|