fase 1
This commit is contained in:
@@ -1,31 +1,563 @@
|
||||
import type { CatalogProvider, Product } from "@/lib/headless/types";
|
||||
import type {
|
||||
CatalogProvider,
|
||||
Product,
|
||||
ProductFilters,
|
||||
ProductVariant,
|
||||
} from "@/lib/headless/types";
|
||||
|
||||
async function medusaFetch<T>(path: string): Promise<T> {
|
||||
const baseUrl = process.env.MEDUSA_BACKEND_URL;
|
||||
if (!baseUrl) throw new Error("Medusa no está configurado.");
|
||||
const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, {
|
||||
headers: process.env.MEDUSA_PUBLISHABLE_KEY
|
||||
? { "x-publishable-api-key": process.env.MEDUSA_PUBLISHABLE_KEY }
|
||||
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 LegacyMeta = {
|
||||
category: string;
|
||||
collection: string;
|
||||
brand: string;
|
||||
ageRange: string;
|
||||
featured: boolean;
|
||||
isNew: boolean;
|
||||
fallbackImage: string;
|
||||
fallbackThumbnail: string;
|
||||
};
|
||||
|
||||
const legacyProductMeta: Record<string, LegacyMeta> = {
|
||||
"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(/\/$/, "");
|
||||
}
|
||||
|
||||
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: 300 },
|
||||
next: {
|
||||
revalidate: 60,
|
||||
tags: ["medusa-products"],
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`Medusa respondió HTTP ${response.status}`);
|
||||
|
||||
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 notImplemented(): never {
|
||||
throw new Error("Completa el mapeo Medusa → Product en lib/headless/providers/medusa.ts.");
|
||||
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 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<Product[]> {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
currency_code: "cop",
|
||||
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() {
|
||||
await medusaFetch<unknown>("/store/products?limit=24");
|
||||
return notImplemented();
|
||||
async getProducts(filters) {
|
||||
return fetchProducts(filters);
|
||||
},
|
||||
async getProductBySlug(): Promise<Product | null> {
|
||||
return notImplemented();
|
||||
|
||||
async getProductBySlug(slug) {
|
||||
const params = new URLSearchParams({
|
||||
handle: slug,
|
||||
limit: "1",
|
||||
currency_code: "cop",
|
||||
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(): Promise<Product[]> {
|
||||
return notImplemented();
|
||||
|
||||
async searchProducts(query, limit = 8) {
|
||||
return fetchProducts({ query }, limit);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user