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 | 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 LegacyMeta = { category: string; collection: string; brand: string; ageRange: string; featured: boolean; isNew: boolean; fallbackImage: string; fallbackThumbnail: string; }; const legacyProductMeta: Record = { "toy-story-5-rex": { "category": "Figuras", "collection": "Toy Story", "brand": "Disney Pixar", "ageRange": "3+", "featured": true, "isNew": true, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-001-toy-story-5-rex.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-001-toy-story-5-rex.webp" }, "toy-story-5-buzz-lightyear": { "category": "Figuras", "collection": "Toy Story", "brand": "Disney Pixar", "ageRange": "3+", "featured": true, "isNew": true, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-002-toy-story-5-buzz-lightyear.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-002-toy-story-5-buzz-lightyear.webp" }, "toy-story-5-jessie": { "category": "Figuras", "collection": "Toy Story", "brand": "Disney Pixar", "ageRange": "3+", "featured": true, "isNew": true, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-003-toy-story-5-jessie.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-003-toy-story-5-jessie.webp" }, "toy-story-5-woody": { "category": "Figuras", "collection": "Toy Story", "brand": "Disney Pixar", "ageRange": "3+", "featured": true, "isNew": true, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-004-toy-story-5-woody.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-004-toy-story-5-woody.webp" }, "intensamente-furia": { "category": "Peluches", "collection": "Intensamente", "brand": "Disney", "ageRange": "3+", "featured": true, "isNew": true, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-005-intensamente-furia.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-005-intensamente-furia.webp" }, "intensamente-tristeza": { "category": "Peluches", "collection": "Intensamente", "brand": "Disney", "ageRange": "3+", "featured": true, "isNew": true, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-006-intensamente-tristeza.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-006-intensamente-tristeza.webp" }, "minnie-y-mickey-mouse": { "category": "Peluches", "collection": "Mickey & Minnie", "brand": "Disney", "ageRange": "3+", "featured": true, "isNew": false, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-007-minnie-y-mickey-mouse.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-007-minnie-y-mickey-mouse.webp" }, "angel-lilo-y-stitch": { "category": "Peluches", "collection": "Lilo & Stitch", "brand": "Disney", "ageRange": "3+", "featured": true, "isNew": false, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-008-angel-lilo-y-stitch.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-008-angel-lilo-y-stitch.webp" }, "mochilas-disney-coleccion-marvel": { "category": "Mochilas", "collection": "Marvel", "brand": "Marvel", "ageRange": "6+", "featured": false, "isNew": false, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-009-mochilas-disney-coleccion-marvel.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-009-mochilas-disney-coleccion-marvel.webp" }, "mochila-ligera-y-resistente-al-agua-de-disney-frozen-mochila-escolar-y-de-viaje-con-multiples-compa": { "category": "Mochilas", "collection": "Frozen", "brand": "Disney", "ageRange": "6+", "featured": false, "isNew": false, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp" }, "mochila-escolar-de-spider-man-de-disney-moda-estudiantil-libro-de-gran-capacidad-mochila-ligera": { "category": "Mochilas", "collection": "Marvel", "brand": "Marvel", "ageRange": "6+", "featured": false, "isNew": false, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-011-mochila-escolar-de-spider-man-de-disney-.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-011-mochila-escolar-de-spider-man-de-disney-.webp" }, "disney-mochila-de-poliester-ultraligera-y-transpirable-de-gran-tamano-y-multiples-colores-dimension": { "category": "Mochilas", "collection": "Disney", "brand": "Disney", "ageRange": "6+", "featured": false, "isNew": false, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-012-disney-mochila-de-poliester-ultraligera-.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-012-disney-mochila-de-poliester-ultraligera-.webp" }, "mochila-pequena-bob-esponja": { "category": "Mochilas", "collection": "Bob Esponja", "brand": "Disney", "ageRange": "6+", "featured": false, "isNew": false, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-013-mochila-pequena-bob-esponja.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-013-mochila-pequena-bob-esponja.webp" }, "mochila-casual-de-gran-capacidad-con-estampado-de-bob-esponja-unisex-bolsa-escolar-de-dibujos-anim": { "category": "Mochilas", "collection": "Bob Esponja", "brand": "Disney", "ageRange": "6+", "featured": false, "isNew": false, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-014-mochila-casual-de-gran-capacidad-con-est.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-014-mochila-casual-de-gran-capacidad-con-est.webp" }, "mochila-escolar-estilo-britanico-de-disney-con-proteccion-de-la-columna-vertebral-nueva-mochila-de": { "category": "Mochilas", "collection": "Disney", "brand": "Disney", "ageRange": "6+", "featured": false, "isNew": false, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-015-mochila-escolar-estilo-britanico-de-disn.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-015-mochila-escolar-estilo-britanico-de-disn.webp" }, "disney-mochila-iron-man-de-poliester-ultraligera-y-transpirable-de-gran-tamano-dimensiones-46-cm-x": { "category": "Mochilas", "collection": "Marvel", "brand": "Marvel", "ageRange": "6+", "featured": false, "isNew": false, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-016-disney-mochila-iron-man-de-poliester-ult.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-016-disney-mochila-iron-man-de-poliester-ult.webp" }, "mochila-de-disney-marvel-capitan-america-de-gran-capacidad-ligera-y-resistente-a-salpicaduras-par": { "category": "Mochilas", "collection": "Marvel", "brand": "Marvel", "ageRange": "6+", "featured": false, "isNew": false, "fallbackImage": "https://shop.cruzcloud.net/images/products/ari-017-mochila-de-disney-marvel-capitan-america.webp", "fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-017-mochila-de-disney-marvel-capitan-america.webp" } }; 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( path: string, searchParams?: URLSearchParams, ): Promise { 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 | null | undefined, key: string, ): string { const value = metadata?.[key]; return typeof value === "string" ? value : ""; } function booleanMetadata( metadata: Record | 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 legacy = legacyProductMeta[slug]; const variant = product.variants?.[0]; const calculated = variant?.calculated_price; const category = product.categories?.[0]?.name || stringMetadata(product.metadata, "category") || legacy?.category || "Variedades"; const collection = product.collection?.title || stringMetadata(product.metadata, "collection") || legacy?.collection || ""; const brand = stringMetadata(product.metadata, "brand") || legacy?.brand || "ARI Shopping"; const ageRange = stringMetadata(product.metadata, "ageRange") || legacy?.ageRange || "Todas"; const images = [ ...(product.images || []) .map((image) => image.url || "") .filter(Boolean), ]; if (images.length === 0 && legacy?.fallbackImage) { images.push(legacy.fallbackImage); } const thumbnail = product.thumbnail || images[0] || legacy?.fallbackThumbnail || "/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") || legacy?.featured || false, isNew: booleanMetadata(product.metadata, "isNew") || legacy?.isNew || false, 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 { 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( "/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( "/store/products", params, ); const product = response.products[0]; return product ? mapProduct(product) : null; }, async searchProducts(query, limit = 8) { return fetchProducts({ query }, limit); }, };