Compare commits

...
Author SHA1 Message Date
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
devops 72ae63f2e3 Merge fix/adr0006-nota-implementacion-2-gate-fase2 into main
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 4m2s
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
2026-08-26 17:12:13 -05:00
gitea-actions ed9891577f chore(gitops): deploy Medusa v1.0.168 [skip ci] 2026-08-26 22:03:37 +00:00
8 changed files with 161 additions and 34 deletions

No files matched your search

@@ -0,0 +1,43 @@
import { searchWithFacets } from "@/lib/headless/client";
import type { ProductFilters } from "@/lib/headless/types";
// PredictiveSearch es un componente cliente y no puede pegarle directo
// al provider server-side (MEDUSA_BACKEND_URL es un nombre DNS interno
// del cluster, no resuelve desde el navegador) -- esta ruta es el
// puente same-origin.
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const filters: ProductFilters = {
query: searchParams.get("q") ?? undefined,
category: searchParams.get("category") ?? undefined,
collection: searchParams.get("collection") ?? undefined,
brand: searchParams.get("brand") ?? undefined,
ageRange: searchParams.get("ageRange") ?? undefined,
};
const minPrice = searchParams.get("minPrice");
if (minPrice) filters.minPrice = Number(minPrice);
const maxPrice = searchParams.get("maxPrice");
if (maxPrice) filters.maxPrice = Number(maxPrice);
try {
const result = await searchWithFacets(filters);
// provider actual no implementa searchWithFacets (mock/shopify) --
// degradar a vacío, no a 500; el componente cliente cae de nuevo a
// su propio filtrado si esta ruta no trae nada útil.
return Response.json(
result ?? { products: [], facetDistribution: {}, count: 0 },
);
} catch (error) {
return Response.json(
{
products: [],
facetDistribution: {},
count: 0,
error: (error as Error).message,
},
{ status: 502 },
);
}
}
@@ -65,7 +65,7 @@ spec:
memory: 64Mi
- name: migrations
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.167
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
imagePullPolicy: IfNotPresent
command:
- node
@@ -86,7 +86,7 @@ spec:
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.167
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
@@ -56,7 +56,7 @@ export function Navbar({ products, categories }: { products: Product[]; categori
<Link href="/" className="relative size-16 overflow-hidden rounded-full border-2 border-slate-900 bg-black shadow-lg lg:size-20">
<Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill priority sizes="80px" className="object-cover" />
</Link>
<PredictiveSearch products={products} />
<PredictiveSearch />
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" aria-label="Mi cuenta" className="hidden border-slate-300 bg-white text-slate-900 hover:border-violet-400 hover:bg-violet-50 sm:inline-flex" onClick={() => setAccountOpen(true)}><UserRound /></Button>
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-fuchsia-400 hover:bg-fuchsia-50 sm:inline-flex" onClick={openWishlist}>
@@ -4,20 +4,45 @@ import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Search, X } from "lucide-react";
import { FormEvent, useMemo, useState } from "react";
import { FormEvent, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import type { Product } from "@/lib/headless/types";
export function PredictiveSearch({ products }: { products: Product[] }) {
export function PredictiveSearch() {
const router = useRouter();
const [query, setQuery] = useState("");
const normalized = query.trim().toLocaleLowerCase("es");
const matches = useMemo(
() => normalized.length < 2
? []
: products.filter((product) => [product.name, product.category, product.collection, product.brand].join(" ").toLocaleLowerCase("es").includes(normalized)).slice(0, 6),
[normalized, products],
);
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();
@@ -36,6 +61,11 @@ export function PredictiveSearch({ products }: { products: Product[] }) {
{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) => (
@@ -26,3 +26,7 @@ export async function searchProducts(query: string, limit?: number) {
export async function getCategories() {
return provider().getCategories();
}
export async function searchWithFacets(filters?: ProductFilters) {
return provider().searchWithFacets?.(filters);
}
@@ -1,6 +1,8 @@
import type {
CatalogProvider,
Category,
FacetDistribution,
FacetedSearchResult,
Product,
ProductFilters,
ProductVariant,
@@ -385,6 +387,56 @@ async function fetchCategories(): Promise<Category[]> {
}));
}
type MedusaSearchResponse = {
products: MedusaProduct[];
facetDistribution?: FacetDistribution;
count?: number;
};
// Pega contra /store/search (ADR 0006 Etapa 2) en vez de /store/products
// -- Meilisearch resuelve texto tolerante a errores de tipeo y facetas
// reales, la ruta del backend re-hidrata precio/inventario vía
// query.graph antes de responder, así que el shape de MedusaProduct es
// exactamente el mismo que ya mapea mapProduct().
async function fetchWithFacets(
filters?: ProductFilters,
limit = 24,
): Promise<FacetedSearchResult> {
const params = new URLSearchParams({
limit: String(limit),
region_id: regionId(),
});
if (filters?.query) params.set("q", filters.query);
if (filters?.category && filters.category !== "Todos") {
params.set("category", filters.category);
}
if (filters?.collection) params.set("collection", filters.collection);
if (filters?.brand && filters.brand !== "Todas") {
params.set("brand", filters.brand);
}
if (filters?.ageRange && filters.ageRange !== "Todas") {
params.set("ageRange", filters.ageRange);
}
if (typeof filters?.minPrice === "number") {
params.set("minPrice", String(filters.minPrice));
}
if (typeof filters?.maxPrice === "number") {
params.set("maxPrice", String(filters.maxPrice));
}
const response = await medusaFetch<MedusaSearchResponse>(
"/store/search",
params,
);
return {
products: response.products.map(mapProduct),
facetDistribution: response.facetDistribution ?? {},
count: response.count ?? response.products.length,
};
}
export const medusaCatalogProvider: CatalogProvider = {
async getProducts(filters) {
return fetchProducts(filters);
@@ -416,6 +468,11 @@ export const medusaCatalogProvider: CatalogProvider = {
},
async searchProducts(query, limit = 8) {
return fetchProducts({ query }, limit);
const { products } = await fetchWithFacets({ query }, limit);
return products;
},
async searchWithFacets(filters) {
return fetchWithFacets(filters);
},
};
+14
View File
@@ -47,9 +47,23 @@ export interface Category {
parentId: string | null;
}
// facet -> valor -> cuantos productos del resultado filtrado tienen ese
// valor (ej. { brand: { Marvel: 5, Disney: 10 } }).
export type FacetDistribution = Record<string, Record<string, number>>;
export interface FacetedSearchResult {
products: Product[];
facetDistribution: FacetDistribution;
count: number;
}
export interface CatalogProvider {
getProducts(filters?: ProductFilters): Promise<Product[]>;
getProductBySlug(slug: string): Promise<Product | null>;
searchProducts(query: string, limit?: number): Promise<Product[]>;
getCategories(): Promise<Category[]>;
// Opcional a propósito: mock.ts y shopify.ts no lo implementan y
// siguen compilando igual -- los llamadores caen de nuevo al
// filtrado/conteo 100% client-side que ya existe cuando no está.
searchWithFacets?(filters?: ProductFilters): Promise<FacetedSearchResult>;
}
@@ -1,21 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend-deploy
spec:
template:
spec:
containers:
- name: web
env:
- name: HEADLESS_PROVIDER
value: medusa
- name: MEDUSA_BACKEND_URL
value: http://medusa-svc:9000
- name: MEDUSA_PUBLISHABLE_KEY
valueFrom:
secretKeyRef:
name: commerce-storefront
key: MEDUSA_PUBLISHABLE_KEY