Files
apps-registry/workloads/ecommerce/lib/headless/providers/medusa.ts
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

479 lines
12 KiB
TypeScript

import type {
CatalogProvider,
Category,
FacetDistribution,
FacetedSearchResult,
Product,
ProductFilters,
ProductVariant,
} from "@/lib/headless/types";
type MedusaImage = {
url?: string | null;
};
type MedusaTag = {
value?: string | null;
};
type MedusaCategory = {
name?: string | null;
};
type MedusaCollection = {
title?: string | null;
};
type MedusaCalculatedPrice = {
calculated_amount?: number | null;
original_amount?: number | null;
currency_code?: string | null;
};
type MedusaVariant = {
id: string;
title?: string | null;
sku?: string | null;
manage_inventory?: boolean | null;
allow_backorder?: boolean | null;
inventory_quantity?: number | null;
calculated_price?: MedusaCalculatedPrice | null;
options?: Array<{
value?: string | null;
option?: {
title?: string | null;
} | null;
}> | null;
};
type MedusaProduct = {
id: string;
handle?: string | null;
title?: string | null;
subtitle?: string | null;
description?: string | null;
thumbnail?: string | null;
external_id?: string | null;
metadata?: Record<string, unknown> | null;
images?: MedusaImage[] | null;
tags?: MedusaTag[] | null;
categories?: MedusaCategory[] | null;
collection?: MedusaCollection | null;
variants?: MedusaVariant[] | null;
};
type MedusaProductsResponse = {
products: MedusaProduct[];
count?: number;
limit?: number;
offset?: number;
};
type MedusaProductCategory = {
id: string;
name?: string | null;
handle?: string | null;
parent_category_id?: string | null;
rank?: number | null;
};
type MedusaCategoriesResponse = {
product_categories: MedusaProductCategory[];
};
function backendUrl(): string {
const value = process.env.MEDUSA_BACKEND_URL;
if (!value) {
throw new Error("MEDUSA_BACKEND_URL no está configurado.");
}
return value.replace(/\/$/, "");
}
function regionId(): string {
const value = process.env.MEDUSA_REGION_ID;
if (!value) {
throw new Error("MEDUSA_REGION_ID no está configurado.");
}
return value;
}
async function medusaFetch<T>(
path: string,
searchParams?: URLSearchParams,
): Promise<T> {
const url = new URL(`${backendUrl()}${path}`);
if (searchParams) {
searchParams.forEach((value, key) => {
url.searchParams.append(key, value);
});
}
const publishableKey = process.env.MEDUSA_PUBLISHABLE_KEY;
const response = await fetch(url, {
headers: publishableKey
? { "x-publishable-api-key": publishableKey }
: undefined,
next: {
revalidate: 60,
tags: ["medusa-products"],
},
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(
`Medusa respondió HTTP ${response.status}: ${body.slice(0, 300)}`,
);
}
return (await response.json()) as T;
}
function stringMetadata(
metadata: Record<string, unknown> | null | undefined,
key: string,
): string {
const value = metadata?.[key];
return typeof value === "string" ? value : "";
}
function booleanMetadata(
metadata: Record<string, unknown> | null | undefined,
key: string,
): boolean {
const value = metadata?.[key];
return value === true || value === "true";
}
function normalizeCurrency(value?: string | null): string {
return (value || "COP").toUpperCase();
}
function mapVariant(variant: MedusaVariant): ProductVariant {
const option = variant.options?.[0];
const available =
variant.allow_backorder === true ||
variant.manage_inventory === false ||
(variant.inventory_quantity ?? 0) > 0;
// null = inventario no gestionado (backorder o manage_inventory=false),
// no hay un numero real que mostrar como "quedan N".
const inventoryQuantity =
variant.allow_backorder === true || variant.manage_inventory === false
? null
: (variant.inventory_quantity ?? 0);
return {
id: variant.id,
name: option?.option?.title || "Presentación",
value: option?.value || variant.title || "Estándar",
available,
inventoryQuantity,
};
}
function mapProduct(product: MedusaProduct): Product {
const slug = product.handle || product.id;
const variant = product.variants?.[0];
const calculated = variant?.calculated_price;
const category =
product.categories?.[0]?.name ||
stringMetadata(product.metadata, "category") ||
"Variedades";
const collection =
product.collection?.title ||
stringMetadata(product.metadata, "collection") ||
"";
const brand = stringMetadata(product.metadata, "brand") || "ARI Shopping";
const ageRange = stringMetadata(product.metadata, "ageRange") || "Todas";
const images = [
...(product.images || [])
.map((image) => image.url || "")
.filter(Boolean),
];
const thumbnail =
product.thumbnail || images[0] || "/images/brand/logo-ari.png";
const price =
typeof calculated?.calculated_amount === "number"
? calculated.calculated_amount
: null;
const original =
typeof calculated?.original_amount === "number"
? calculated.original_amount
: null;
const compareAtPrice =
original !== null && price !== null && original > price
? original
: null;
const available =
!variant ||
variant.allow_backorder === true ||
variant.manage_inventory === false ||
(variant.inventory_quantity ?? 0) > 0;
const tags = (product.tags || [])
.map((tag) => tag.value || "")
.filter(Boolean);
return {
id: product.external_id || product.id,
slug,
name: product.title || "Producto ARI Shopping",
shortDescription:
product.subtitle ||
product.title ||
"Producto ARI Shopping",
description:
product.description ||
"Producto disponible para confirmación de inventario con ARI Shopping.",
category,
collection,
brand,
ageRange,
price,
compareAtPrice,
currency: normalizeCurrency(calculated?.currency_code),
priceLabel: price === null ? "Consultar disponibilidad" : "",
available,
featured: booleanMetadata(product.metadata, "featured"),
isNew: booleanMetadata(product.metadata, "isNew"),
image: images[0] || thumbnail,
thumbnail,
images: images.length > 0 ? images : [thumbnail],
tags,
variants: (product.variants || []).map(mapVariant),
};
}
function matchesFilters(
product: Product,
filters?: ProductFilters,
): boolean {
if (!filters) return true;
const query = filters.query?.trim().toLocaleLowerCase("es");
if (
query &&
![
product.name,
product.shortDescription,
product.description,
product.category,
product.collection,
product.brand,
...product.tags,
]
.join(" ")
.toLocaleLowerCase("es")
.includes(query)
) {
return false;
}
if (
filters.category &&
filters.category !== "Todos" &&
product.category !== filters.category
) {
return false;
}
if (
filters.collection &&
product.collection !== filters.collection
) {
return false;
}
if (
filters.brand &&
filters.brand !== "Todas" &&
product.brand !== filters.brand
) {
return false;
}
if (
filters.ageRange &&
filters.ageRange !== "Todas" &&
product.ageRange !== filters.ageRange
) {
return false;
}
if (
typeof filters.minPrice === "number" &&
product.price !== null &&
product.price < filters.minPrice
) {
return false;
}
if (
typeof filters.maxPrice === "number" &&
product.price !== null &&
product.price > filters.maxPrice
) {
return false;
}
return true;
}
async function fetchProducts(
filters?: ProductFilters,
limit = 100,
): Promise<Product[]> {
const params = new URLSearchParams({
limit: String(limit),
// El validador de /store/products no acepta currency_code (400
// "Unrecognized fields"); el precio calculado solo se resuelve con
// region_id.
region_id: regionId(),
fields:
"+metadata,+images,+tags,+categories.*,+collection,+variants.inventory_quantity,+variants.calculated_price,+variants.options",
});
if (filters?.query) {
params.set("q", filters.query);
}
const response = await medusaFetch<MedusaProductsResponse>(
"/store/products",
params,
);
return response.products
.map(mapProduct)
.filter((product) => matchesFilters(product, filters));
}
async function fetchCategories(): Promise<Category[]> {
const params = new URLSearchParams({
limit: "100",
fields: "id,name,handle,rank,parent_category_id",
});
const response = await medusaFetch<MedusaCategoriesResponse>(
"/store/product-categories",
params,
);
// Ordenar por rank preserva el orden de la taxonomia tal como se creo
// (ver ADR 0005) -- los hijos de cada padre quedan en el mismo orden
// relativo porque el arbol se arma filtrando este arreglo ya ordenado.
return response.product_categories
.slice()
.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))
.map((category) => ({
id: category.id,
name: category.name || "",
handle: category.handle || category.id,
parentId: category.parent_category_id ?? null,
}));
}
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);
},
async getCategories() {
return fetchCategories();
},
async getProductBySlug(slug) {
const params = new URLSearchParams({
handle: slug,
limit: "1",
// El validador de /store/products no acepta currency_code (400
// "Unrecognized fields"); el precio calculado solo se resuelve con
// region_id.
region_id: regionId(),
fields:
"+metadata,+images,+tags,+categories.*,+collection,+variants.inventory_quantity,+variants.calculated_price,+variants.options",
});
const response = await medusaFetch<MedusaProductsResponse>(
"/store/products",
params,
);
const product = response.products[0];
return product ? mapProduct(product) : null;
},
async searchProducts(query, limit = 8) {
const { products } = await fetchWithFacets({ query }, limit);
return products;
},
async searchWithFacets(filters) {
return fetchWithFacets(filters);
},
};