Files
apps-registry/workloads/ecommerce/components/search/predictive-search.tsx
T
devops 902350b58b Fase 3a ADR 0006 Etapa 2: buscador del navbar via Meilisearch
PredictiveSearch pasa de filtrar en memoria el arreglo completo de
productos (prop products, substring match) a un fetch debounced
(250ms, con AbortController) contra la nueva ruta /api/search --
mismo componente visual, solo cambia de donde salen los datos, tal
como pide el ADR 0006 seccion 5.

/api/search (nueva, server-side) es el puente same-origin: el
componente es cliente y no puede pegarle directo al provider
server-side (MEDUSA_BACKEND_URL es DNS interno del cluster, no
resuelve desde el navegador).

CatalogProvider gana un metodo OPCIONAL searchWithFacets? (no cambia
la firma de getProducts/searchProducts) -- mock.ts y shopify.ts
compilan sin tocarlos, siguen sin implementarlo. medusa.ts lo
implementa contra /store/search (Fase 2), reutilizando mapProduct()
ya que la ruta devuelve el mismo shape de producto que /store/products.
searchProducts tambien se repunta a /store/search, dejando de usar el
?q= + re-filtro client-side de fetchProducts/matchesFilters para busqueda
(que sigue sirviendo para navegacion sin busqueda, sin cambios).

Navbar deja de pasarle products a PredictiveSearch (ya no los necesita)
pero los sigue recibiendo el mismo -- collections todavia se deriva de
ahi.

Limpieza: borrado patches/frontend-medusa-env.yaml, confirmado
huerfano (ningun kustomization lo referencia; el patch que si esta
vivo es frontend/patches/deployment.yaml).

Validado: build completo de Docker (target builder) -- npm ci,
next build con typecheck, /api/search aparece en la tabla de rutas
generada. Cero errores nuevos (los warnings de ESLint son
preexistentes en catalog-client.tsx, no tocado en esta fase).
2026-08-26 17:30:47 -05:00

83 lines
3.7 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 { Input } from "@/components/ui/input";
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="rounded-full bg-gradient-to-r from-cyan-400 via-violet-500 to-fuchsia-500 p-[2px] shadow-lg shadow-violet-500/10">
<div className="relative rounded-full bg-white">
<Search className="pointer-events-none absolute left-5 top-1/2 z-10 size-5 -translate-y-1/2 text-violet-600" />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Busca juguetes, mochilas, personajes…" className="h-[52px] border-0 bg-transparent pl-[52px] pr-12 shadow-none focus-visible:ring-violet-300/30" />
{query && <button type="button" aria-label="Limpiar búsqueda" className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 hover:bg-slate-100" onClick={() => setQuery("")}><X className="size-4" /></button>}
</div>
</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>
)}
{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-violet-50" 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-slate-950 px-4 py-3 text-sm font-black text-white hover:bg-violet-700">Buscar “{query.trim()}” en todo el catálogo</button>
</div>
)}
</form>
);
}