Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdab538cfb | ||
|
|
f124ae0bbd | ||
|
|
b8ce9ded87 | ||
|
|
b12dd848af | ||
|
|
d28ee4a49d | ||
|
|
3fc77c6cdc | ||
|
|
14c473eb8b | ||
|
|
93e2401e3b |
No files matched your search
@@ -25,7 +25,7 @@ spec:
|
|||||||
runAsNonRoot: false
|
runAsNonRoot: false
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.206
|
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.207
|
||||||
# El base saca TODAS las capabilities. Probado en vivo y
|
# El base saca TODAS las capabilities. Probado en vivo y
|
||||||
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
|
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
|
||||||
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
|
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ spec:
|
|||||||
runAsUser: 1001
|
runAsUser: 1001
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.204
|
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.209
|
||||||
env:
|
env:
|
||||||
- name: HEADLESS_PROVIDER
|
- name: HEADLESS_PROVIDER
|
||||||
value: medusa
|
value: medusa
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// 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;
|
||||||
|
product?: { images?: Array<{ url?: string | null }> | null } | 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",
|
||||||
|
// item.thumbnail viene null en la practica para todo el catalogo
|
||||||
|
// real (confirmado contra /store/products: product.thumbnail
|
||||||
|
// tambien es null ahi, incluso con fotos reales cargadas) -- el
|
||||||
|
// catalogo ya resuelve esto cayendo a images[0] (mapProduct() en
|
||||||
|
// medusa.ts), asi que la linea del carrito pide esa misma relacion
|
||||||
|
// explicita (no viene por defecto) y aplica el mismo fallback.
|
||||||
|
thumbnail: item.thumbnail || item.product?.images?.[0]?.url || "/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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// *items.product.images no viene por defecto (verificado en vivo) y es
|
||||||
|
// lo unico que necesitamos ademas del set por defecto -- *items ya trae
|
||||||
|
// unit_price/product_title/variant_id/quantity sin pedirlos aparte.
|
||||||
|
const ITEMS_FIELDS = "fields=*items,*items.product.images";
|
||||||
|
|
||||||
|
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}?${ITEMS_FIELDS}`);
|
||||||
|
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?${ITEMS_FIELDS}`, {
|
||||||
|
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}?${ITEMS_FIELDS}`, {
|
||||||
|
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}?${ITEMS_FIELDS}`, {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
Reference in new issue
Block a user