From 3fc77c6cdcfb7075aa3c1cdf49d5f08aade6fc6a Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Sat, 29 Aug 2026 02:42:29 +0000 Subject: [PATCH] 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. --- .../app/api/cart/items/[lineItemId]/route.ts | 31 ++++ .../ecommerce/app/api/cart/items/route.ts | 14 ++ workloads/ecommerce/app/api/cart/route.ts | 28 +++ .../lib/headless/providers/medusa-cart.ts | 164 ++++++++++++++++++ workloads/ecommerce/store/cart-store.ts | 119 +++++++++---- 5 files changed, 325 insertions(+), 31 deletions(-) create mode 100644 workloads/ecommerce/app/api/cart/items/[lineItemId]/route.ts create mode 100644 workloads/ecommerce/app/api/cart/items/route.ts create mode 100644 workloads/ecommerce/app/api/cart/route.ts create mode 100644 workloads/ecommerce/lib/headless/providers/medusa-cart.ts diff --git a/workloads/ecommerce/app/api/cart/items/[lineItemId]/route.ts b/workloads/ecommerce/app/api/cart/items/[lineItemId]/route.ts new file mode 100644 index 0000000..a144c1c --- /dev/null +++ b/workloads/ecommerce/app/api/cart/items/[lineItemId]/route.ts @@ -0,0 +1,31 @@ +import { removeLineItem, updateLineItemQuantity } from "@/lib/headless/providers/medusa-cart"; + +interface RouteParams { params: Promise<{ lineItemId: string }> } + +export async function POST(request: Request, { params }: RouteParams) { + const { lineItemId } = await params; + const { cartId, quantity } = await request.json(); + if (!cartId || typeof quantity !== "number") { + return Response.json({ error: "cartId y quantity son obligatorios" }, { status: 400 }); + } + try { + const cart = await updateLineItemQuantity(cartId, lineItemId, quantity); + return Response.json(cart); + } catch (error) { + return Response.json({ error: (error as Error).message }, { status: 502 }); + } +} + +export async function DELETE(request: Request, { params }: RouteParams) { + const { lineItemId } = await params; + const cartId = new URL(request.url).searchParams.get("cartId"); + if (!cartId) { + return Response.json({ error: "cartId es obligatorio" }, { status: 400 }); + } + try { + const cart = await removeLineItem(cartId, lineItemId); + return Response.json(cart); + } catch (error) { + return Response.json({ error: (error as Error).message }, { status: 502 }); + } +} diff --git a/workloads/ecommerce/app/api/cart/items/route.ts b/workloads/ecommerce/app/api/cart/items/route.ts new file mode 100644 index 0000000..86c40ac --- /dev/null +++ b/workloads/ecommerce/app/api/cart/items/route.ts @@ -0,0 +1,14 @@ +import { addLineItem } from "@/lib/headless/providers/medusa-cart"; + +export async function POST(request: Request) { + const { cartId, variantId, quantity } = await request.json(); + if (!cartId || !variantId) { + return Response.json({ error: "cartId y variantId son obligatorios" }, { status: 400 }); + } + try { + const cart = await addLineItem(cartId, variantId, quantity ?? 1); + return Response.json(cart); + } catch (error) { + return Response.json({ error: (error as Error).message }, { status: 502 }); + } +} diff --git a/workloads/ecommerce/app/api/cart/route.ts b/workloads/ecommerce/app/api/cart/route.ts new file mode 100644 index 0000000..c26e4f4 --- /dev/null +++ b/workloads/ecommerce/app/api/cart/route.ts @@ -0,0 +1,28 @@ +import { createCart, getCart } from "@/lib/headless/providers/medusa-cart"; + +// cart-store.ts es "use client" y no puede pegarle directo a Medusa +// (MEDUSA_BACKEND_URL es DNS interno del cluster, no resuelve desde el +// navegador) -- mismo motivo que /api/search. Nunca cachear: es +// estado mutable por sesion, no catalogo. + +export async function POST() { + try { + const cart = await createCart(); + return Response.json(cart); + } catch (error) { + return Response.json({ error: (error as Error).message }, { status: 502 }); + } +} + +export async function GET(request: Request) { + const cartId = new URL(request.url).searchParams.get("cartId"); + if (!cartId) { + return Response.json({ error: "cartId es obligatorio" }, { status: 400 }); + } + try { + const cart = await getCart(cartId); + return Response.json(cart); + } catch (error) { + return Response.json({ error: (error as Error).message }, { status: 502 }); + } +} diff --git a/workloads/ecommerce/lib/headless/providers/medusa-cart.ts b/workloads/ecommerce/lib/headless/providers/medusa-cart.ts new file mode 100644 index 0000000..e1a0a1c --- /dev/null +++ b/workloads/ecommerce/lib/headless/providers/medusa-cart.ts @@ -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> { + 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 { + 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 { + const data = await medusaCartFetch(`/store/carts/${cartId}`); + return mapCart(data.cart as MedusaCart); +} + +export async function addLineItem( + cartId: string, + variantId: string, + quantity: number, +): Promise { + 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 { + 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 { + 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); +} diff --git a/workloads/ecommerce/store/cart-store.ts b/workloads/ecommerce/store/cart-store.ts index f24b9b8..f5a9e4d 100644 --- a/workloads/ecommerce/store/cart-store.ts +++ b/workloads/ecommerce/store/cart-store.ts @@ -2,60 +2,117 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -import type { Product } from "@/lib/headless/types"; +import type { CartLine } from "@/lib/headless/providers/medusa-cart"; -export interface CartItem { - product: Product; - quantity: number; - variantId?: string; -} +// ADR 0009: carrito real conectado a un cart_id de Medusa (Store API, +// via /api/cart/*) en vez de estado 100% local -- precios y existencia +// de linea son los que confirma el servidor, no un calculo optimista +// en el cliente. Primer corte a proposito: sin UI de direccion/envio +// todavia, el checkout sigue cerrando por WhatsApp (CartDrawer). +export type CartItem = CartLine; interface CartState { + cartId: string | null; items: CartItem[]; isOpen: boolean; hasHydrated: boolean; openCart: () => void; closeCart: () => void; setHasHydrated: (value: boolean) => void; - addItem: (product: Product, variantId?: string) => void; - removeItem: (productId: string) => void; - updateQuantity: (productId: string, quantity: number) => void; - clearCart: () => void; + addItem: (product: { id: string }, variantId?: string) => Promise; + removeItem: (productId: string) => Promise; + updateQuantity: (productId: string, quantity: number) => Promise; + clearCart: () => Promise; + syncCart: () => Promise; +} + +async function postJson(url: string, body: unknown) { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return response.json(); } export const useCartStore = create()( persist( - (set) => ({ + (set, get) => ({ + cartId: null, items: [], isOpen: false, hasHydrated: false, openCart: () => set({ isOpen: true }), closeCart: () => set({ isOpen: false }), setHasHydrated: (value) => set({ hasHydrated: value }), - addItem: (product, variantId) => set((state) => { - const existing = state.items.find( - (item) => item.product.id === product.id && item.variantId === variantId, + + addItem: async (product, variantId) => { + set({ isOpen: true }); + if (!variantId) return; + + let cartId = get().cartId; + if (!cartId) { + const cart = await (await fetch("/api/cart", { method: "POST" })).json(); + if (!cart.cartId) return; + cartId = cart.cartId; + set({ cartId, items: cart.items ?? [] }); + } + + const cart = await postJson("/api/cart/items", { cartId, variantId, quantity: 1 }); + if (cart.items) set({ items: cart.items }); + }, + + removeItem: async (productId) => { + const { cartId, items } = get(); + const line = items.find((item) => item.product.id === productId); + if (!cartId || !line) return; + const response = await fetch(`/api/cart/items/${line.lineItemId}?cartId=${cartId}`, { method: "DELETE" }); + const cart = await response.json(); + if (cart.items) set({ items: cart.items }); + }, + + updateQuantity: async (productId, quantity) => { + if (quantity <= 0) return get().removeItem(productId); + const { cartId, items } = get(); + const line = items.find((item) => item.product.id === productId); + if (!cartId || !line) return; + const cart = await postJson(`/api/cart/items/${line.lineItemId}`, { cartId, quantity }); + if (cart.items) set({ items: cart.items }); + }, + + clearCart: async () => { + const { cartId, items } = get(); + if (!cartId) return; + await Promise.all( + items.map((item) => fetch(`/api/cart/items/${item.lineItemId}?cartId=${cartId}`, { method: "DELETE" })), ); - const items = existing - ? state.items.map((item) => item === existing ? { ...item, quantity: item.quantity + 1 } : item) - : [...state.items, { product, quantity: 1, variantId }]; - return { items, isOpen: true }; - }), - removeItem: (productId) => set((state) => ({ - items: state.items.filter((item) => item.product.id !== productId), - })), - updateQuantity: (productId, quantity) => set((state) => ({ - items: quantity <= 0 - ? state.items.filter((item) => item.product.id !== productId) - : state.items.map((item) => item.product.id === productId ? { ...item, quantity } : item), - })), - clearCart: () => set({ items: [] }), + set({ items: [] }); + }, + + // Al recargar la pagina, el cart_id guardado en localStorage puede + // llevar dias -- se refresca contra el servidor en vez de confiar en + // el ultimo snapshot local (precios/stock pudieron cambiar). + syncCart: async () => { + const cartId = get().cartId; + if (!cartId) return; + try { + const response = await fetch(`/api/cart?cartId=${cartId}`); + if (!response.ok) return; + const cart = await response.json(); + if (cart.items) set({ items: cart.items }); + } catch { + // Sin red: se queda con el ultimo estado local conocido. + } + }, }), { - name: "ari-shopping-cart-v4", + name: "ari-shopping-cart-v5", storage: createJSONStorage(() => localStorage), - partialize: (state) => ({ items: state.items }), - onRehydrateStorage: () => (state) => state?.setHasHydrated(true), + partialize: (state) => ({ cartId: state.cartId, items: state.items }), + onRehydrateStorage: () => (state) => { + state?.setHasHydrated(true); + void state?.syncCart(); + }, }, ), );