From 902350b58bfd162fc363ecabe9053fb547d7aab2 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Wed, 26 Aug 2026 17:30:47 -0500 Subject: [PATCH] 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). --- workloads/ecommerce/app/api/search/route.ts | 43 ++++++++++++++ .../ecommerce/components/layout/navbar.tsx | 2 +- .../components/search/predictive-search.tsx | 48 ++++++++++++--- workloads/ecommerce/lib/headless/client.ts | 4 ++ .../lib/headless/providers/medusa.ts | 59 ++++++++++++++++++- workloads/ecommerce/lib/headless/types.ts | 14 +++++ .../patches/frontend-medusa-env.yaml | 21 ------- 7 files changed, 159 insertions(+), 32 deletions(-) create mode 100644 workloads/ecommerce/app/api/search/route.ts delete mode 100644 workloads/ecommerce/patches/frontend-medusa-env.yaml diff --git a/workloads/ecommerce/app/api/search/route.ts b/workloads/ecommerce/app/api/search/route.ts new file mode 100644 index 0000000..c55a0e8 --- /dev/null +++ b/workloads/ecommerce/app/api/search/route.ts @@ -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 }, + ); + } +} diff --git a/workloads/ecommerce/components/layout/navbar.tsx b/workloads/ecommerce/components/layout/navbar.tsx index 1ae0ecc..f8d9335 100644 --- a/workloads/ecommerce/components/layout/navbar.tsx +++ b/workloads/ecommerce/components/layout/navbar.tsx @@ -56,7 +56,7 @@ export function Navbar({ products, categories }: { products: Product[]; categori ARI Shopping - +
}
+ {loading && normalized.length >= 2 && matches.length === 0 && ( +
+ Buscando… +
+ )} {matches.length > 0 && (
{matches.map((product) => ( diff --git a/workloads/ecommerce/lib/headless/client.ts b/workloads/ecommerce/lib/headless/client.ts index fe291da..f60db80 100644 --- a/workloads/ecommerce/lib/headless/client.ts +++ b/workloads/ecommerce/lib/headless/client.ts @@ -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); +} diff --git a/workloads/ecommerce/lib/headless/providers/medusa.ts b/workloads/ecommerce/lib/headless/providers/medusa.ts index 0b1f1b0..211f60b 100644 --- a/workloads/ecommerce/lib/headless/providers/medusa.ts +++ b/workloads/ecommerce/lib/headless/providers/medusa.ts @@ -1,6 +1,8 @@ import type { CatalogProvider, Category, + FacetDistribution, + FacetedSearchResult, Product, ProductFilters, ProductVariant, @@ -385,6 +387,56 @@ async function fetchCategories(): Promise { })); } +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 { + 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( + "/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); }, }; diff --git a/workloads/ecommerce/lib/headless/types.ts b/workloads/ecommerce/lib/headless/types.ts index 5f9cced..f92783b 100644 --- a/workloads/ecommerce/lib/headless/types.ts +++ b/workloads/ecommerce/lib/headless/types.ts @@ -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>; + +export interface FacetedSearchResult { + products: Product[]; + facetDistribution: FacetDistribution; + count: number; +} + export interface CatalogProvider { getProducts(filters?: ProductFilters): Promise; getProductBySlug(slug: string): Promise; searchProducts(query: string, limit?: number): Promise; getCategories(): Promise; + // 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; } diff --git a/workloads/ecommerce/patches/frontend-medusa-env.yaml b/workloads/ecommerce/patches/frontend-medusa-env.yaml deleted file mode 100644 index 45c9810..0000000 --- a/workloads/ecommerce/patches/frontend-medusa-env.yaml +++ /dev/null @@ -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