feat(ecommerce): ADR 0009 - carrito real conectado a Medusa (Store API)

Primer corte de checkout real, a proposito acotado: el carrito pasa de
estado 100% local (localStorage con productos completos) a sincronizar
con un cart_id real de Medusa via /api/cart/* (rutas puente
same-origin, mismo patron que /api/search -- MEDUSA_BACKEND_URL es DNS
interno del cluster, no resuelve desde el navegador).

Formas de respuesta de la API verificadas en vivo con un carrito de
prueba real antes de escribir el mapeo (no asumidas contra
documentacion): agregar el mismo variant_id dos veces hace merge de
cantidad automatico (no crea linea duplicada); eliminar una linea
devuelve el carrito actualizado en 'parent', no en 'cart' como los
demas endpoints.

La firma publica de useCartStore (addItem/removeItem/updateQuantity/
items) no cambia, asi que ningun componente consumidor (cart-drawer,
navbar, product-card, product-purchase, mobile-add-to-cart) necesito
tocarse. Sigue cerrando por WhatsApp -- direccion/envio/pago quedan
para los siguientes cortes del ADR 0009.
This commit is contained in:
devops committed 2026-08-29 02:42:29 +00:00
1 parent 14c473eb8b
commit 3fc77c6cdc
5 files changed
+325 -31

No files matched your search

@@ -0,0 +1,164 @@
// ADR 0009: carrito real conectado a Medusa (Store API), en vez del
// estado 100% local que existia antes. Archivo separado de medusa.ts
// a proposito -- ese es de solo lectura (catalogo, cacheado con
// revalidate/tags), este es de escritura (carrito, siempre no-store,
// nunca debe cachearse).
//
// Formas de respuesta verificadas en vivo contra la API real
// (2026-08-29), no asumidas contra documentacion:
// - crear/agregar/actualizar linea: { cart: {...} }
// - eliminar linea: { id, object, deleted, parent: {...} } -- el
// carrito actualizado viene en "parent", no en "cart".
// - agregar el mismo variant_id dos veces hace merge de cantidad en la
// misma linea (confirmado con un carrito de prueba real), no crea
// una linea duplicada -- por eso addLineItem siempre manda
// quantity=1 sin revisar antes si ya existe.
export interface CartLineProduct {
id: string;
name: string;
thumbnail: string;
price: number | null;
currency: string;
}
export interface CartLine {
lineItemId: string;
product: CartLineProduct;
quantity: number;
variantId: string;
}
export interface CartSummary {
cartId: string;
currency: string;
subtotal: number;
items: CartLine[];
}
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;
}
type MedusaLineItem = {
id: string;
product_id: string;
variant_id: string;
title?: string | null;
product_title?: string | null;
thumbnail?: string | null;
quantity: number;
unit_price?: number | null;
};
type MedusaCart = {
id: string;
currency_code: string;
subtotal?: number | null;
items?: MedusaLineItem[] | null;
};
function mapCart(cart: MedusaCart): CartSummary {
return {
cartId: cart.id,
currency: cart.currency_code,
subtotal: cart.subtotal ?? 0,
items: (cart.items ?? []).map((item) => ({
lineItemId: item.id,
variantId: item.variant_id,
quantity: item.quantity,
product: {
id: item.product_id,
name: item.product_title || item.title || "Producto",
// La linea del carrito no trae el arreglo de imagenes del producto,
// solo este campo (a veces null) -- mismo fallback final que ya usa
// mapProduct() en medusa.ts.
thumbnail: item.thumbnail || "/images/brand/logo-ari.png",
price: typeof item.unit_price === "number" ? item.unit_price : null,
currency: cart.currency_code,
},
})),
};
}
async function medusaCartFetch(
path: string,
init?: { method?: string; body?: unknown },
): Promise<Record<string, unknown>> {
const publishableKey = process.env.MEDUSA_PUBLISHABLE_KEY;
const response = await fetch(`${backendUrl()}${path}`, {
method: init?.method ?? "GET",
headers: {
"Content-Type": "application/json",
...(publishableKey ? { "x-publishable-api-key": publishableKey } : {}),
},
body: init?.body ? JSON.stringify(init.body) : undefined,
cache: "no-store",
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Medusa cart API ${response.status}: ${text.slice(0, 300)}`);
}
return response.json();
}
export async function createCart(): Promise<CartSummary> {
const data = await medusaCartFetch("/store/carts", {
method: "POST",
body: { region_id: regionId() },
});
return mapCart(data.cart as MedusaCart);
}
export async function getCart(cartId: string): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}`);
return mapCart(data.cart as MedusaCart);
}
export async function addLineItem(
cartId: string,
variantId: string,
quantity: number,
): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items`, {
method: "POST",
body: { variant_id: variantId, quantity },
});
return mapCart(data.cart as MedusaCart);
}
export async function updateLineItemQuantity(
cartId: string,
lineItemId: string,
quantity: number,
): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items/${lineItemId}`, {
method: "POST",
body: { quantity },
});
return mapCart(data.cart as MedusaCart);
}
export async function removeLineItem(cartId: string, lineItemId: string): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items/${lineItemId}`, {
method: "DELETE",
});
// Forma distinta a las demas: el carrito actualizado viene en
// "parent", no en "cart" -- verificado en vivo, no documentado.
return mapCart(data.parent as MedusaCart);
}