diff --git a/src/components/Search/SmartSearchBar.jsx b/src/components/Search/SmartSearchBar.jsx new file mode 100644 index 0000000..0a66945 --- /dev/null +++ b/src/components/Search/SmartSearchBar.jsx @@ -0,0 +1,206 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { recoSearch } from "../../services/recoSearch"; +import "../../styles/smartSearchBar.css"; + +function defaultZoomForKind(kind) { + const k = (kind || "").toLowerCase(); + if (k.includes("post")) return 15; + if (k.includes("place") || k.includes("poi")) return 13; + if (k.includes("region") || k.includes("area") || k.includes("city")) return 9; + return 12; +} + +function IconPin(props) { + return ( + + ); +} + +function IconWaves(props) { + return ( + + ); +} + +/** + * Props: + * - placeholder + * - onPick(item, { zoom, reason }) // call when user selects suggestion + * - onMySpot() // keep same function (center on user) + * - onPlaceTourWire() // keep same function (place/tour/wire) + */ +export default function SmartSearchBar({ + placeholder = "Search places, regions, posts…", + onPick, + 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 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); + }, []); + + useEffect(() => { + const query = q.trim(); + setErr(""); + + if (abortRef.current) abortRef.current.abort(); + if (!query) { + setItems([]); + setLoading(false); + setActive(-1); + return; + } + + const ctrl = new AbortController(); + abortRef.current = ctrl; + setLoading(true); + + const t = setTimeout(async () => { + try { + const res = await recoSearch(query, { signal: ctrl.signal, limit: 10 }); + setItems(res); + setOpen(true); + setActive(res.length ? 0 : -1); + } catch (e) { + if (ctrl.signal.aborted) return; + setErr((e && e.message) ? e.message : "Search error"); + setItems([]); + } finally { + if (!ctrl.signal.aborted) setLoading(false); + } + }, 220); + + return () => { + clearTimeout(t); + ctrl.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") { + if (items.length && active >= 0) { + e.preventDefault(); + pick(items[active], "enter"); + } + } 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 ( +