Merge fix/checkout-carrito-real-medusa into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 19s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m42s

This commit is contained in:
devops committed 2026-08-29 02:42:32 +00:00
commit d28ee4a49d
5 files changed
+325 -31

No files matched your search

@@ -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 });
}
}
@@ -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 });
}
}
+28
View File
@@ -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 });
}
}
@@ -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);
}
+88 -31
View File
@@ -2,60 +2,117 @@
import { create } from "zustand"; import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware"; 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 { // ADR 0009: carrito real conectado a un cart_id de Medusa (Store API,
product: Product; // via /api/cart/*) en vez de estado 100% local -- precios y existencia
quantity: number; // de linea son los que confirma el servidor, no un calculo optimista
variantId?: string; // 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 { interface CartState {
cartId: string | null;
items: CartItem[]; items: CartItem[];
isOpen: boolean; isOpen: boolean;
hasHydrated: boolean; hasHydrated: boolean;
openCart: () => void; openCart: () => void;
closeCart: () => void; closeCart: () => void;
setHasHydrated: (value: boolean) => void; setHasHydrated: (value: boolean) => void;
addItem: (product: Product, variantId?: string) => void; addItem: (product: { id: string }, variantId?: string) => Promise<void>;
removeItem: (productId: string) => void; removeItem: (productId: string) => Promise<void>;
updateQuantity: (productId: string, quantity: number) => void; updateQuantity: (productId: string, quantity: number) => Promise<void>;
clearCart: () => void; clearCart: () => Promise<void>;
syncCart: () => Promise<void>;
}
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<CartState>()( export const useCartStore = create<CartState>()(
persist( persist(
(set) => ({ (set, get) => ({
cartId: null,
items: [], items: [],
isOpen: false, isOpen: false,
hasHydrated: false, hasHydrated: false,
openCart: () => set({ isOpen: true }), openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }), closeCart: () => set({ isOpen: false }),
setHasHydrated: (value) => set({ hasHydrated: value }), setHasHydrated: (value) => set({ hasHydrated: value }),
addItem: (product, variantId) => set((state) => {
const existing = state.items.find( addItem: async (product, variantId) => {
(item) => item.product.id === product.id && item.variantId === 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 set({ items: [] });
? state.items.map((item) => item === existing ? { ...item, quantity: item.quantity + 1 } : item) },
: [...state.items, { product, quantity: 1, variantId }];
return { items, isOpen: true }; // Al recargar la pagina, el cart_id guardado en localStorage puede
}), // llevar dias -- se refresca contra el servidor en vez de confiar en
removeItem: (productId) => set((state) => ({ // el ultimo snapshot local (precios/stock pudieron cambiar).
items: state.items.filter((item) => item.product.id !== productId), syncCart: async () => {
})), const cartId = get().cartId;
updateQuantity: (productId, quantity) => set((state) => ({ if (!cartId) return;
items: quantity <= 0 try {
? state.items.filter((item) => item.product.id !== productId) const response = await fetch(`/api/cart?cartId=${cartId}`);
: state.items.map((item) => item.product.id === productId ? { ...item, quantity } : item), if (!response.ok) return;
})), const cart = await response.json();
clearCart: () => set({ items: [] }), 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), storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ items: state.items }), partialize: (state) => ({ cartId: state.cartId, items: state.items }),
onRehydrateStorage: () => (state) => state?.setHasHydrated(true), onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
void state?.syncCart();
},
}, },
), ),
); );