feat(ecommerce): migrate Ari Shopping to Next.js 15 headless storefront v4
Build and Push Frontend / build (push) Failing after 5m39s

This commit is contained in:
2026-07-19 19:37:41 -05:00
parent 383b644929
commit f263a9cc55
76 changed files with 1607 additions and 0 deletions
@@ -0,0 +1,24 @@
import type { CatalogProvider, ProductFilters } from "@/lib/headless/types";
import { medusaCatalogProvider } from "@/lib/headless/providers/medusa";
import { mockCatalogProvider } from "@/lib/headless/providers/mock";
import { shopifyCatalogProvider } from "@/lib/headless/providers/shopify";
function provider(): CatalogProvider {
switch (process.env.HEADLESS_PROVIDER) {
case "shopify": return shopifyCatalogProvider;
case "medusa": return medusaCatalogProvider;
default: return mockCatalogProvider;
}
}
export async function getProducts(filters?: ProductFilters) {
return provider().getProducts(filters);
}
export async function getProductBySlug(slug: string) {
return provider().getProductBySlug(slug);
}
export async function searchProducts(query: string, limit?: number) {
return provider().searchProducts(query, limit);
}
@@ -0,0 +1,31 @@
import type { CatalogProvider, Product } 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 }
: undefined,
next: { revalidate: 300 },
});
if (!response.ok) throw new Error(`Medusa respondió HTTP ${response.status}`);
return (await response.json()) as T;
}
function notImplemented(): never {
throw new Error("Completa el mapeo Medusa → Product en lib/headless/providers/medusa.ts.");
}
export const medusaCatalogProvider: CatalogProvider = {
async getProducts() {
await medusaFetch<unknown>("/store/products?limit=24");
return notImplemented();
},
async getProductBySlug(): Promise<Product | null> {
return notImplemented();
},
async searchProducts(): Promise<Product[]> {
return notImplemented();
},
};
@@ -0,0 +1,42 @@
import catalog from "@/data/products.json";
import type { CatalogProvider, Product, ProductFilters } from "@/lib/headless/types";
const products = catalog satisfies Product[];
function normalize(value: string): string {
return value.toLocaleLowerCase("es").normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function matches(product: Product, filters: ProductFilters): boolean {
if (filters.category && filters.category !== "Todos" && product.category !== filters.category) return false;
if (filters.collection && product.collection !== filters.collection) return false;
if (filters.brand && product.brand !== filters.brand) return false;
if (filters.ageRange && product.ageRange !== filters.ageRange) return false;
if (filters.minPrice !== undefined && product.price !== null && product.price < filters.minPrice) return false;
if (filters.maxPrice !== undefined && product.price !== null && product.price > filters.maxPrice) return false;
if (filters.query) {
const query = normalize(filters.query);
const haystack = normalize([
product.name,
product.description,
product.category,
product.collection,
product.brand,
...product.tags,
].join(" "));
if (!haystack.includes(query)) return false;
}
return true;
}
export const mockCatalogProvider: CatalogProvider = {
async getProducts(filters = {}) {
return products.filter((product) => matches(product, filters));
},
async getProductBySlug(slug) {
return products.find((product) => product.slug === slug) ?? null;
},
async searchProducts(query, limit = 6) {
return products.filter((product) => matches(product, { query })).slice(0, limit);
},
};
@@ -0,0 +1,45 @@
import type { CatalogProvider, Product } from "@/lib/headless/types";
interface ShopifyGraphQLResponse<T> {
data?: T;
errors?: Array<{ message: string }>;
}
async function shopifyFetch<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
const url = process.env.SHOPIFY_STOREFRONT_URL;
const token = process.env.SHOPIFY_STOREFRONT_TOKEN;
if (!url || !token) throw new Error("Shopify Storefront API no está configurada.");
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Storefront-Access-Token": token,
},
body: JSON.stringify({ query, variables }),
next: { revalidate: 300 },
});
if (!response.ok) throw new Error(`Shopify respondió HTTP ${response.status}`);
const payload = (await response.json()) as ShopifyGraphQLResponse<T>;
if (payload.errors?.length) throw new Error(payload.errors.map((error) => error.message).join("; "));
if (!payload.data) throw new Error("Shopify no devolvió datos.");
return payload.data;
}
function notImplemented(): never {
throw new Error("Completa el mapeo Shopify → Product en lib/headless/providers/shopify.ts.");
}
export const shopifyCatalogProvider: CatalogProvider = {
async getProducts() {
await shopifyFetch<{ products: unknown }>(`query Products { products(first: 24) { nodes { id title handle } } }`);
return notImplemented();
},
async getProductBySlug(): Promise<Product | null> {
return notImplemented();
},
async searchProducts(): Promise<Product[]> {
return notImplemented();
},
};
+45
View File
@@ -0,0 +1,45 @@
export interface ProductVariant {
id: string;
name: string;
value: string;
available: boolean;
}
export interface Product {
id: string;
slug: string;
name: string;
shortDescription: string;
description: string;
category: string;
collection: string;
brand: string;
ageRange: string;
price: number | null;
compareAtPrice: number | null;
currency: string;
priceLabel: string;
available: boolean;
featured: boolean;
isNew: boolean;
image: string;
images: string[];
tags: string[];
variants: ProductVariant[];
}
export interface ProductFilters {
query?: string;
category?: string;
collection?: string;
brand?: string;
ageRange?: string;
minPrice?: number;
maxPrice?: number;
}
export interface CatalogProvider {
getProducts(filters?: ProductFilters): Promise<Product[]>;
getProductBySlug(slug: string): Promise<Product | null>;
searchProducts(query: string, limit?: number): Promise<Product[]>;
}