Compare commits

...
Author SHA1 Message Date
devops b8ce9ded87 fix(ecommerce): carrito muestra la foto real del producto
Reportado en produccion: el carrito mostraba el logo generico de ARI
en vez de la foto del producto. Verificado en vivo: item.thumbnail
viene null en la respuesta de Medusa para todo el catalogo real
(confirmado tambien contra /store/products directamente -- el campo
thumbnail del producto es null en la fuente, no solo omitido en la
respuesta del carrito). El catalogo ya resuelve esto cayendo a
images[0] (mapProduct() en medusa.ts); la linea del carrito necesita
pedir esa relacion explicita via fields=*items.product.images (no
viene por defecto) y aplicar el mismo fallback.
2026-08-29 03:24:09 +00:00
devops d28ee4a49d 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
2026-08-29 02:42:32 +00:00
devops 3fc77c6cdc 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.
2026-08-29 02:42:29 +00:00
gitea-actions 14c473eb8b chore(gitops): deploy Docs Portal v1.0.207 [skip ci] 2026-08-29 01:29:23 +00:00
devops 93e2401e3b Merge docs/adr0009-correccion-plugin-payu into main
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 6m9s
2026-08-29 01:23:17 +00:00
devops 615cf9ce77 docs(decisiones): ADR 0009 - correccion, no hay plugin real para PayU LatAm
Verificado el codigo/README real de los dos unicos paquetes de Medusa
que aparecen buscando 'PayU' (@tax1driver/medusa-payu y
medusa-plugin-payu de rigby-sh, este ultimo archivado desde
2025-03-21): ambos usan la terminologia de la API de PayU Polonia/CEE
(clientId/posId/secondKey, OAuth), no la de PayU LatAm
(apiLogin/apiKey/accountId, developers.payulatam.com, la que cubre
Colombia). 'PayU' es una marca compartida por dos productos distintos.

La decision de negocio (PayU como proveedor) se mantiene -- cambia la
via tecnica: integracion via modulo de pago custom
(AbstractPaymentProvider de Medusa) contra la API real de PayU LatAm,
mismo tipo de esfuerzo que EnvioClick, no instalacion de plugin.

De paso, documenta las shipping options ya creadas y verificadas en
produccion (stock location, fulfillment set, service zone Colombia,
shipping option de 5.000 COP) incluyendo el paso no documentado que
las dejaba invisibles para el Store API (vinculo a sales channel).
2026-08-29 01:23:15 +00:00
7 changed files with 408 additions and 54 deletions

No files matched your search

@@ -126,28 +126,29 @@ Verificado contra fuentes reales el mismo día (no memoria):
los métodos que se pidieron (PSE + Nequi "y demás bancos") sin que
la cuenta bancaria propia del comercio sea Bancolombia.
**Decisión:** PayU, vía el plugin comunitario `@tax1driver/medusa-payu`
(compatibilidad declarada con Medusa 2.4.0+, cubre nuestra versión
2.18.0). Wompi queda descartado por el requisito de registro
Bancolombia/Nequi, no por limitación técnica del plugin en sí.
**Decisión:** PayU como proveedor de pago (negocio) se mantiene. La vía
técnica cambió el 2026-08-29 — ver nota de implementación al final de
este documento: ningún plugin comunitario de Medusa sirve para PayU
LatAm, hace falta un módulo de pago custom. Wompi queda descartado por
el requisito de registro Bancolombia/Nequi, no por limitación técnica.
**Pendiente antes de implementar (no resoluble en este ADR):**
- Registro real de comercio en PayU (persona natural o jurídica —
jurídica pide NIT, representante legal y estados financieros
básicos) y credenciales reales (API keys, merchant ID, webhook
config) — nunca generadas ni asumidas por mí.
- Verificar el código fuente real de `@tax1driver/medusa-payu` en el
tag exacto a instalar antes de confiar en su documentación, misma
disciplina que ya destapó desviaciones reales en el plugin de
Meilisearch (ADR 0006).
básicos) y credenciales reales (`apiLogin`, `apiKey`, `merchantId`,
`accountId` — terminología real de la API de PayU LatAm, ver nota de
implementación) — nunca generadas ni asumidas por mí.
- Escribir el módulo de pago custom para PayU LatAm (no instalar un
plugin — no existe ninguno real para esta API, ver nota de
implementación).
## Opciones consideradas
| Opción | A favor | En contra |
|---|---|---|
| **Stripe** | Integración core de Medusa, mejor documentada, configuración mínima | No acepta comercios registrados en Colombia — bloqueador de negocio, no técnico |
| **PayU** (`@tax1driver/medusa-payu`) — **elegido** | Compatibilidad declarada explícitamente con Medusa 2.4.0+ (cubre 2.18.0); solo exige cuenta bancaria colombiana a nombre del comercio, sin banco específico (encaja con Davivienda/Lulo Bank/Banco de Bogotá); soporta PSE + Nequi + Daviplata para el cliente sin importar el banco del comercio | Plugin comunitario, no de primera clase; requiere la misma verificación de código fuente que ya se le hizo al plugin de Meilisearch antes de confiar en él |
| **PayU** — **elegido** (proveedor de negocio; integración técnica ahora es custom, ver nota de implementación) | Solo exige cuenta bancaria colombiana a nombre del comercio, sin banco específico (encaja con Davivienda/Lulo Bank/Banco de Bogotá); soporta PSE + Nequi + Daviplata para el cliente sin importar el banco del comercio | Ningún plugin de Medusa real sirve para su API LatAm (`apiLogin`/`apiKey`/`accountId`) — los dos paquetes que parecían encajar por nombre son en realidad para PayU Polonia/CEE (`clientId`/`posId`/`secondKey`), uno de ellos archivado; requiere módulo de pago custom |
| **Wompi** (`medusa-payment-wompi-col`) | Liquidación directa en COP vía Bancolombia | Exige cuenta Bancolombia o, como mínimo, Nequi solo para **registrarse** como comercio — ninguno de los bancos disponibles (Davivienda/Lulo Bank/Banco de Bogotá) califica sin abrir una cuenta nueva; además, sin versión de Medusa documentada ni actividad reciente visible en el plugin |
| **Seguir con WhatsApp** (no pago en línea) | Cero riesgo nuevo, cero dependencia externa | No resuelve el objetivo real (transacción completa sin intervención manual); no es checkout, es lo que ya existe hoy |
@@ -156,17 +157,14 @@ Bancolombia/Nequi, no por limitación técnica del plugin en sí.
- Las shipping options son un prerequisito independiente del proveedor
y deben configurarse primero — sin ellas ningún flujo de checkout
completa, sea cual sea el pago.
- Al ser `@tax1driver/medusa-payu` un plugin comunitario (no de
primera clase como Stripe), exige la misma disciplina de
verificación que ya destapó desviaciones reales en el plugin de
Meilisearch (ADR 0006, nota de implementación): leer el código
fuente en el tag exacto que se va a instalar, no el README genérico
ni asumir que "compatibilidad declarada" significa "verificado en
este proyecto".
- Los webhooks de pago (registrados por el plugin en
`/hooks/payment/payu`) deben validarse en vivo contra la cadena de
red real del lab antes de confiar en el flujo, dado el precedente de
`incidente-cloudflared-504-quic-2026-08.md`.
- El pago de PayU se implementa como módulo custom (`AbstractPaymentProvider`
de Medusa) contra la API real de PayU LatAm, no como instalación de
un plugin de terceros — más superficie propia para mantener, mismo
patrón de esfuerzo que ya se identificó para EnvíoClick en §2.
- Los webhooks de pago (a definir en el módulo custom, apuntando a la
URL de notificación real de PayU LatAm) deben validarse en vivo
contra la cadena de red real del lab antes de confiar en el flujo,
dado el precedente de `incidente-cloudflared-504-quic-2026-08.md`.
- Se acepta como deuda: si en el futuro se abre una cuenta Bancolombia
o Nequi, Wompi (liquidación más directa en COP) vuelve a ser una
opción válida a reevaluar — no se descarta por limitación técnica,
@@ -174,3 +172,56 @@ Bancolombia/Nequi, no por limitación técnica del plugin en sí.
- Revertir esta decisión implica deshabilitar el provider en la región
y volver al botón de WhatsApp — el carrito y el catálogo no cambian
de forma, solo el paso final.
## Nota de implementación (2026-08-29): shipping options creadas
Bloqueador de §1 resuelto en producción, verificado extremo a extremo
contra el Store API real (no solo creado en Admin):
- Stock location "Bodega principal ARI Shopping" (`sloc_01M15G3D1336V8WQW3VZQRP6AN`).
- Fulfillment set + zona de servicio "Colombia" (`geo_zone` tipo país, `co`).
- Provider `manual_manual` habilitado en la ubicación.
- Shipping option "Envío nacional estándar", tarifa fija $15.000 COP
(placeholder de negocio dentro del rango real de mercado
$12.000–$18.000 que dio el usuario, no un cálculo por peso/volumen —
eso es justamente lo que EnvíoClick resolvería más adelante),
vinculada al Default Shipping Profile y a la región Colombia/COP.
- **Paso no documentado en ningún lado y que rompía todo en silencio:**
la stock location tiene que estar vinculada al Sales Channel
(`/admin/stock-locations/{id}/sales-channels`) para que el Store API
devuelva la shipping option — sin esto, `GET /store/shipping-options`
devolvía `[]` vacío pese a que el fulfillment set, la zona de
servicio y el provider ya estaban bien configurados. Confirmado con
un carrito de prueba real (dirección `co` + `cart_id`) antes y
después del vínculo.
## Nota de implementación 2 (2026-08-29): corrección — no existe plugin real para PayU LatAm
Antes de instalar nada se verificó el código/README real de los dos
únicos paquetes de Medusa que aparecen buscando "PayU" — misma
disciplina que ya aplicó ADR 0006 al plugin de Meilisearch, esta vez
con un resultado distinto: **ninguno de los dos sirve.**
- `@tax1driver/medusa-payu`: opciones de configuración `clientId`,
`clientSecret`, `merchantPosId`, `secondKey` — terminología exacta
de la API de **PayU Polonia/CEE** (OAuth + `posId`), no de PayU
LatAm.
- `medusa-plugin-payu` (`rigby-sh/medusa-plugin-payu`): mismo problema
(`clientId`, `posId`, sandbox en `secure.snd.payu.com`, dominio de
PayU Europa) y además **archivado desde 2025-03-21** — sin
mantenimiento.
- La API real que se necesita, PayU LatAm
(`developers.payulatam.com`, la que cubre Colombia junto con
Argentina/Brasil/Chile/México/Panamá/Perú), autentica con
`apiLogin`/`apiKey` en el objeto merchant y `accountId` en el
objeto order — un modelo de autenticación distinto, no solo nombres
de campo distintos.
"PayU" es una marca compartida por dos productos/API históricamente
separados (LatAm vs. Europa/CEE); un paquete que dice "PayU" a secas
sin especificar LatAm es, con alta probabilidad, para el producto
europeo. **Consecuencia:** la integración de pago se implementa como
módulo custom de Medusa (`AbstractPaymentProvider`) contra
`developers.payulatam.com` directamente — mismo tipo de esfuerzo que
ya se había anticipado para EnvíoClick en §2, no una instalación de
plugin como asumía la decisión original de este ADR.
@@ -25,7 +25,7 @@ spec:
runAsNonRoot: false
containers:
- 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
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
# 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 });
}
}
+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,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);
}
+88 -31
View File
@@ -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<void>;
removeItem: (productId: string) => Promise<void>;
updateQuantity: (productId: string, quantity: number) => Promise<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>()(
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();
},
},
),
);