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(); + }, }, ), );