Los 17 productos legacy ya tienen category, collection, brand, ageRange, imagenes y featured/isNew reales en Medusa (migrados en fix #2/#2b de Etapa 2 sub-fase 1), por lo que el fallback hardcodeado en mapProduct() ya no aporta nada y solo duplica datos que pueden desincronizarse.
372 lines
8.5 KiB
TypeScript
372 lines
8.5 KiB
TypeScript
import type {
|
|
CatalogProvider,
|
|
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;
|
|
};
|
|
|
|
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;
|
|
|
|
return {
|
|
id: variant.id,
|
|
name: option?.option?.title || "Presentación",
|
|
value: option?.value || variant.title || "Estándar",
|
|
available,
|
|
};
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
export const medusaCatalogProvider: CatalogProvider = {
|
|
async getProducts(filters) {
|
|
return fetchProducts(filters);
|
|
},
|
|
|
|
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) {
|
|
return fetchProducts({ query }, limit);
|
|
},
|
|
};
|