Encontrado probando en vivo con navegador real (Docker + Playwright): una búsqueda de 2+ caracteres sin coincidencias no mostraba nada -- ni "Buscando…" ni ningún mensaje. El usuario quedaba sin feedback de que su búsqueda de verdad corrió. Se agrega un estado vacío con el botón de buscar en todo el catálogo (esa búsqueda completa puede usar criterios más amplios que el autocompletado). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
116 lines
5.9 KiB
TypeScript
116 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { Search, X } from "lucide-react";
|
|
import { FormEvent, useEffect, useState } from "react";
|
|
import type { Product } from "@/lib/headless/types";
|
|
|
|
export function PredictiveSearch() {
|
|
const router = useRouter();
|
|
const [query, setQuery] = useState("");
|
|
const [matches, setMatches] = useState<Product[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const normalized = query.trim();
|
|
|
|
useEffect(() => {
|
|
if (normalized.length < 2) {
|
|
setMatches([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
setLoading(true);
|
|
const timer = setTimeout(() => {
|
|
fetch(`/api/search?q=${encodeURIComponent(normalized)}`, {
|
|
signal: controller.signal,
|
|
})
|
|
.then((response) => response.json())
|
|
.then((data: { products?: Product[] }) => {
|
|
setMatches((data.products ?? []).slice(0, 6));
|
|
})
|
|
.catch((error) => {
|
|
if ((error as Error).name !== "AbortError") setMatches([]);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, 250);
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
controller.abort();
|
|
};
|
|
}, [normalized]);
|
|
|
|
const submit = (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const value = query.trim();
|
|
if (!value) return;
|
|
router.push(`/catalog?q=${encodeURIComponent(value)}`);
|
|
setQuery("");
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={submit} className="relative w-full">
|
|
<div className="flex items-center rounded-full border-2 border-slate-200 bg-white pl-5 pr-1.5 shadow-sm transition focus-within:border-ari-marino focus-within:ring-4 focus-within:ring-ari-coral-claro/30">
|
|
{/* input propio (no el <Input> compartido) -- ese componente trae su
|
|
propio focus:ring-4 focus:ring-cyan-300/30 hardcodeado que no se
|
|
anulaba de forma confiable via tailwind-merge dentro de este
|
|
contenedor compuesto (input + boton circular), causando un
|
|
segundo ring visible al hacer foco. Mas simple y robusto no
|
|
heredar clases de foco que hay que pelear despues.
|
|
|
|
focus-visible:outline-none (no outline-none a secas): globals.css
|
|
define un outline de accesibilidad global por teclado
|
|
(input:focus-visible, azul de marca) que le gana en
|
|
especificidad a un outline-none plano. El wrapper de este
|
|
componente ya tiene su propio indicador de foco visible
|
|
(focus-within:border-ari-marino + ring amarillo, mismo patron
|
|
que button.tsx) que cumple el mismo proposito de accesibilidad
|
|
sin duplicarlo -- variante focus-visible: en vez de la base
|
|
iguala la especificidad de la regla global (mismo truco que ya
|
|
usa button.tsx) y evita el doble indicador de foco. */}
|
|
<input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Busca juguetes, mochilas, personajes…"
|
|
className="h-[48px] w-full border-0 bg-transparent p-0 text-sm text-slate-950 focus-visible:outline-none"
|
|
/>
|
|
{query && <button type="button" aria-label="Limpiar búsqueda" className="mr-1 shrink-0 rounded-full p-1.5 text-slate-400 hover:bg-slate-100" onClick={() => setQuery("")}><X className="size-4" /></button>}
|
|
<button type="submit" aria-label="Buscar" className="grid size-10 shrink-0 place-items-center rounded-full bg-ari-marino text-white transition hover:brightness-110">
|
|
<Search className="size-4.5" />
|
|
</button>
|
|
</div>
|
|
{loading && normalized.length >= 2 && matches.length === 0 && (
|
|
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 rounded-2xl border border-slate-200 bg-white p-4 text-center text-sm text-slate-500 shadow-2xl">
|
|
Buscando…
|
|
</div>
|
|
)}
|
|
{/* Encontrado probando en vivo con navegador real: sin este bloque,
|
|
una busqueda de 2+ caracteres sin coincidencias no mostraba nada
|
|
(ni "buscando" ni un mensaje) -- el usuario se quedaba sin ningun
|
|
feedback de que su busqueda de verdad corrio. Se ofrece igual el
|
|
boton de buscar en todo el catalogo porque esa busqueda completa
|
|
puede usar criterios mas amplios que el autocompletado. */}
|
|
{!loading && normalized.length >= 2 && matches.length === 0 && (
|
|
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 rounded-2xl border border-slate-200 bg-white p-2 text-center shadow-2xl">
|
|
<p className="p-3 text-sm text-slate-500">No encontramos resultados para “{query.trim()}”.</p>
|
|
<button type="submit" className="w-full rounded-xl bg-ari-marino px-4 py-3 text-sm font-black text-white hover:brightness-110">Buscar “{query.trim()}” en todo el catálogo</button>
|
|
</div>
|
|
)}
|
|
{matches.length > 0 && (
|
|
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 overflow-hidden rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl">
|
|
{matches.map((product) => (
|
|
<Link key={product.id} href={`/product/${product.slug}`} prefetch={false} className="grid grid-cols-[54px_1fr] items-center gap-3 rounded-xl p-2 hover:bg-ari-marino/5" onClick={() => setQuery("")}>
|
|
<div className="relative aspect-square overflow-hidden rounded-lg bg-slate-100"><Image src={product.thumbnail} alt="" fill sizes="54px" className="object-cover" /></div>
|
|
<div><strong className="line-clamp-1 text-sm">{product.name}</strong><span className="text-xs text-slate-500">{product.category} · {product.collection}</span></div>
|
|
</Link>
|
|
))}
|
|
<button type="submit" className="mt-1 w-full rounded-xl bg-ari-marino px-4 py-3 text-sm font-black text-white hover:brightness-110">Buscar “{query.trim()}” en todo el catálogo</button>
|
|
</div>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|