Compare commits

..
Author SHA1 Message Date
devopsandClaude Sonnet 5 a5ede05d96 fix(product-import): generar handle ASCII-safe en vez del default de Medusa
3 productos importados con título acentuado (ñ/tildes) quedaron con handle
no-ASCII y daban 404 real al entrar por shop.cruzcloud.net -- el tunel/
ingress público rompe rutas con UTF-8 no-ASCII en el path, confirmado en
vivo comparando el mismo request directo al servicio (200 OK) contra el
dominio público (404). Se genera el handle explícito (slugify propio,
sin dependencia nueva) con sufijo único por fila para evitar colisiones
entre títulos parecidos. Los 3 productos ya publicados con handle roto
se corrigieron en caliente vía Admin API, sin esperar este deploy.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SEsGCsCmxkyXi9XctNdrT1
2026-09-06 03:43:47 +00:00
devopsandClaude Sonnet 5 63746eb090 Merge origin/main (chore: deploy Medusa v1.0.222) into local main
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SEsGCsCmxkyXi9XctNdrT1
2026-09-06 03:21:58 +00:00
devopsandClaude Sonnet 5 2ca00d032d Merge fix/adr0008-doc-estado-final-palette-amendment into main
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SEsGCsCmxkyXi9XctNdrT1
2026-09-06 03:21:43 +00:00
devopsandClaude Sonnet 5 cb15749448 docs(adr-0008): registrar pivote de paleta azul/amarillo y estado real por sub-fase
La spec original (Navy #122844/Coral #D85A30) fue reemplazada en producción
el mismo día por una paleta azul/amarillo estilo Walmart
(fix/adr0008-walmart-palette-blue-yellow), pero nunca quedó documentada
como enmienda. Se agrega §8 con los valores reales vigentes en globals.css
y §9 con auditoría por sub-fase contra el código en main: sub-fases 1-4
completas, sub-fase 5 parcial (product-purchase migrado, cart-drawer y
wishlist-drawer sin tocar, alias de compatibilidad de button.tsx sin
eliminar).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SEsGCsCmxkyXi9XctNdrT1
2026-09-06 03:21:36 +00:00
gitea-actions 9e1adf4627 chore(gitops): deploy Medusa v1.0.222 [skip ci] 2026-09-04 14:18:07 +00:00
devops 0ad74b6b70 Merge branch 'chore/product-import-flat-bucket-note' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 26s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 23m5s
2026-09-04 13:54:31 +00:00
devops d1d007fc7d chore(commerce-backend): corregir comentario sobre carpeta de fotos importadas
El S3 file provider de Medusa descarta cualquier subcarpeta del
filename (usa solo path.parse().name + ulid, confirmado leyendo
@medusajs/file-s3/dist/services/s3-file.js) -- no existe una carpeta
import-products/ separada como decia el comentario original. El bucket
(ari-products) es plano, igual para todas las fotos del catalogo.
Confirmado en vivo con una importacion real de 3 productos.
2026-09-04 13:54:23 +00:00
gitea-actions 11ac6d7949 chore(gitops): deploy Medusa v1.0.221 [skip ci] 2026-09-04 05:27:25 +00:00
devops 093ae419d5 Merge branch 'fix/product-import-category-select' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 16m51s
2026-09-04 04:35:28 +00:00
devops 2b0d50c3d0 fix(commerce-backend): traer name/title explicito en el importador
listProductCategories({})/listProductCollections({}) sin select
explicito devolvian name/title undefined (a diferencia de la capa HTTP
del Admin API, que si los trae completos por defecto) -- causaba un 500
"Cannot read properties of undefined (reading 'toLowerCase')" en
cualquier import real. Confirmado en vivo con una prueba de importacion
de 3 productos antes de este fix.

De paso, se agrega un filtro defensivo antes del .map() para que una
categoria/coleccion sin nombre no tumbe el import entero.
2026-09-04 04:35:20 +00:00
gitea-actions 8126bb3d81 chore(gitops): deploy Medusa v1.0.220 [skip ci] 2026-09-02 21:53:50 +00:00
devops dedf4f0963 Merge branch 'feat/product-import' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 59s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 26m14s
2026-09-01 03:47:43 +00:00
devops 1cd1078cdf feat(commerce-backend): importador masivo de productos via CSV
Nueva pagina "Importacion" en Medusa Admin para cargar varios productos
de una sola vez (comprados en Amazon/eBay/etc, no dropshipping
automatizado -- se evaluo y descarto raspar catalogos de terceros sin
autorizacion por riesgo de copyright/ToS).

src/api/admin/product-import/route.ts: GET sirve la plantilla CSV
descargable, POST procesa fila por fila (una foto rota o categoria mal
escrita no tumba el resto del archivo). Cada foto se descarga del origen
y se re-aloja en el mismo MinIO/S3 del NAS que ya usa el catalogo
(prefijo import-products/, separado de ari-products/), se crea el
registro de inventario con la cantidad indicada y el producto queda
vinculado con el mismo patron "Presentacion"/"Estandar" que usa el resto
del catalogo -- el storefront no necesita cambios.

src/lib/csv.ts: parser CSV propio (RFC4180), sin sumar dependencia nueva.

Workflows y servicios de modulo (uploadFilesWorkflow,
createInventoryItemsWorkflow, createProductsWorkflow, stock-location/
fulfillment/sales-channel/product module services) verificados contra
los .d.ts reales instalados, no solo contra documentacion.
2026-09-01 03:47:32 +00:00
devops 601378a77e chore: release v1.0.219 [skip ci] 2026-08-29 20:31:52 +00:00
devops 1dccd0aa6a Merge branch 'feat/out-of-stock-ui' into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m30s
2026-08-29 20:23:02 +00:00
devops 474ac3795b fix(ecommerce): deshabilitar compra y mostrar "Agotado" sin stock real
variant.available (calculado en lib/headless/providers/medusa.ts a
partir de manage_inventory/allow_backorder/inventory_quantity) ya
llegaba correcto hasta el componente, pero ningun boton de compra lo
usaba -- se podia seguir agregando al carrito un producto con stock 0.

Afecta product-purchase.tsx (pagina de producto), mobile-add-to-cart.tsx
(barra fija mobile) y product-card.tsx (tarjetas de catalogo): boton
deshabilitado + label "Agotado" en los tres, mas badge/overlay en la
tarjeta de catalogo.
2026-08-29 20:22:55 +00:00
gitea-actions bb2ad3c317 chore(gitops): deploy Medusa v1.0.218 [skip ci] 2026-08-29 19:19:15 +00:00
devops 578f085334 Merge branch 'fix/inventory-panel-ts-error' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 25s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 18m56s
2026-08-29 18:59:55 +00:00
devops ce4842f375 fix(commerce-backend): corregir tipo de callback en location_levels.find
TS2769: la anotacion explicita {location_id: string} no calzaba con el
tipo Maybe<InventoryLevel> que infiere query.graph para location_levels.
Se quita la anotacion y se deja inferir, con optional chaining.
2026-08-29 18:59:48 +00:00
devops 2cce671049 Merge branch 'feat/inventory-panel' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 32s
Build and Push Medusa / Construir y publicar Medusa (push) Failing after 4m52s
2026-08-29 17:56:51 +00:00
devops 22f97ee12e feat(commerce-backend): panel simplificado de inventario en Medusa Admin
Nueva pestana "Inventario" en el sidebar del Admin (src/admin/routes/
inventario), pensada para un usuario no tecnico: una fila por producto
(catalogo real confirmado 1 variante por producto), switch para activar
seguimiento de stock, input de cantidad que guarda solo, badge de stock
bajo (<=5 unidades) y activacion/desactivacion en bloque por seleccion.
No reemplaza la vista nativa de Inventory de Medusa, es un atajo.

Backend: src/api/admin/inventory-panel/route.ts + workflow propio
src/workflows/attach-inventory-item-to-variant.ts para vincular un
InventoryItem nuevo a una variante existente (Medusa no lo hace solo al
togglear manage_inventory en un producto ya creado -- confirmado en vivo
contra un producto [DEMO] antes de escribir el codigo).

Nombres de campos y workflows verificados contra los .d.ts reales
instalados en el pod (no solo contra la documentacion, que no cubre bien
este flujo de vincular inventario a una variante existente).
2026-08-29 17:56:44 +00:00
devops 42c90d8534 chore: release v1.0.216 [skip ci] 2026-08-29 17:10:35 +00:00
devops cd8d2edd8f Merge branch 'feat/google-oauth-frontend' into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m39s
2026-08-29 17:01:39 +00:00
devops 77389aea19 feat(ecommerce): login con Google para clientes
Implementa el flujo completo de OAuth social de Medusa para el
storefront: boton "Continuar con Google" en account-dialog, ruta proxy
que arranca el flujo (GET /api/customer/google/start) y pagina de
callback en /auth/google/callback (coincide con GOOGLE_CALLBACK_URL del
secret) que resuelve el code/state contra el backend, decodifica el JWT
para distinguir cliente nuevo vs existente, crea el customer si hace
falta y refresca el token a sesion final -- mismo patron de
store/customer-store.ts que login/register por email.

Flujo verificado en vivo contra el backend ya desplegado (GET
/auth/customer/google, /auth/customer/google/callback con state
invalido, /auth/token/refresh con bearer invalido) antes de escribir el
codigo, no solo contra la documentacion.
2026-08-29 17:01:32 +00:00
gitea-actions 7ce11ecbb4 chore(gitops): deploy Medusa v1.0.215 [skip ci] 2026-08-29 16:34:34 +00:00
devops da10a4b688 Merge branch 'fix/google-oauth-build-env' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 19s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 21m48s
2026-08-29 16:12:33 +00:00
20 changed files with 1169 additions and 10 deletions

No files matched your search

@@ -0,0 +1,9 @@
import Medusa from "@medusajs/js-sdk";
// baseUrl "/" -- esta pagina se sirve dentro del mismo dashboard admin
// (mismo origen que la API), no hace falta apuntar a otra URL. auth
// "session" reusa la cookie de sesion ya autenticada del dashboard.
export const sdk = new Medusa({
baseUrl: "/",
auth: { type: "session" },
});
@@ -0,0 +1,145 @@
import { defineRouteConfig } from "@medusajs/admin-sdk";
import { ArrowDownTray, ArrowUpTray, CheckCircleSolid, XCircleSolid } from "@medusajs/icons";
import { Badge, Button, Container, Heading, Text, toast } from "@medusajs/ui";
import { useRef, useState } from "react";
import { sdk } from "../../lib/sdk";
// Importador masivo via CSV -- pensado para cargar varios productos de
// una sola vez (comprados en Amazon/eBay/etc, no dropshipping
// automatizado -- ver conversacion sobre por que se descarto raspar
// tiendas de terceros). Las fotos se descargan del origen y se re-alojan
// en el mismo MinIO/S3 del NAS que ya usa el resto del catalogo.
type RowResult = {
row: number;
title: string;
ok: boolean;
error?: string;
};
function downloadTemplate() {
window.open("/admin/product-import", "_blank");
}
const ProductImportPanel = () => {
const inputRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
const [results, setResults] = useState<RowResult[] | null>(null);
async function handleFile(file: File) {
const csv = await file.text();
setLoading(true);
setResults(null);
try {
const data = await sdk.client.fetch<{ results?: RowResult[]; error?: string }>("/admin/product-import", {
method: "post",
body: { csv },
});
if (data.error) {
toast.error("No se pudo procesar el archivo", { description: data.error });
return;
}
setResults(data.results ?? []);
const okCount = data.results?.filter((r) => r.ok).length ?? 0;
const failCount = (data.results?.length ?? 0) - okCount;
if (failCount === 0) {
toast.success(`${okCount} productos importados`);
} else {
toast.warning(`${okCount} importados, ${failCount} con error`, {
description: "Revisa el detalle abajo.",
});
}
} catch (error) {
toast.error("No se pudo procesar el archivo", { description: (error as Error).message });
} finally {
setLoading(false);
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<div>
<Heading level="h2">Importación de productos</Heading>
<Text size="small" className="text-ui-fg-subtle">
Carga varios productos a la vez desde un archivo CSV, con fotos, descripción y precio.
</Text>
</div>
<div className="flex gap-2">
<Button size="small" variant="secondary" onClick={downloadTemplate}>
<ArrowDownTray />
Descargar plantilla
</Button>
<Button size="small" variant="primary" isLoading={loading} onClick={() => inputRef.current?.click()}>
<ArrowUpTray />
Subir CSV
</Button>
<input
ref={inputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) handleFile(file);
}}
/>
</div>
</div>
<div className="px-6 py-4">
<Text size="small" className="text-ui-fg-subtle">
Columnas: <code>titulo, descripcion, precio, categoria, coleccion, cantidad, fotos</code>. Varias fotos se
separan con <code>;</code> dentro de la misma celda. <code>categoria</code>/<code>coleccion</code> deben
escribirse igual que en la tienda o se dejan vacías. Cada foto se descarga y se guarda en el almacenamiento
propio de la tienda (no queda enlazada al sitio de origen).
</Text>
</div>
{results && (
<div className="px-6 py-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-ui-fg-subtle">
<th className="w-10 py-2" />
<th className="py-2 font-medium">Fila</th>
<th className="py-2 font-medium">Producto</th>
<th className="py-2 font-medium">Detalle</th>
</tr>
</thead>
<tbody>
{results.map((result) => (
<tr key={result.row} className="border-b last:border-0">
<td className="py-2">
{result.ok ? (
<CheckCircleSolid className="text-ui-tag-green-icon" />
) : (
<XCircleSolid className="text-ui-tag-red-icon" />
)}
</td>
<td className="py-2">{result.row}</td>
<td className="py-2">{result.title}</td>
<td className="py-2">
{result.ok ? (
<Badge color="green">Importado</Badge>
) : (
<span className="text-rose-600">{result.error}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Container>
);
};
export const config = defineRouteConfig({
label: "Importación",
icon: ArrowUpTray,
});
export default ProductImportPanel;
@@ -0,0 +1,194 @@
import { defineRouteConfig } from "@medusajs/admin-sdk";
import { Buildings } from "@medusajs/icons";
import { Badge, Button, Checkbox, Container, Heading, Input, Switch, Text, toast } from "@medusajs/ui";
import { useEffect, useState } from "react";
import { sdk } from "../../lib/sdk";
// Panel simplificado para alguien no tecnico (la esposa del dueno del
// negocio) -- una fila por producto (este catalogo es 1 variante por
// producto, confirmado en vivo), switch para activar seguimiento de
// stock y un input de cantidad que guarda solo. La vista nativa de
// Medusa Admin (Inventory) sigue disponible para el dueno, esto no la
// reemplaza, es un atajo.
const LOW_STOCK_THRESHOLD = 5;
type Row = {
productId: string;
variantId: string | null;
title: string;
thumbnail: string | null;
sku: string | null;
managed: boolean;
quantity: number;
};
async function fetchRows(): Promise<Row[]> {
const data = await sdk.client.fetch<{ rows: Row[] }>("/admin/inventory-panel");
return data.rows;
}
async function saveRow(row: { productId: string; variantId: string; managed?: boolean; quantity?: number }) {
await sdk.client.fetch("/admin/inventory-panel", { method: "post", body: row });
}
const InventoryPanel = () => {
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [savingId, setSavingId] = useState<string | null>(null);
useEffect(() => {
fetchRows().then((data) => {
setRows(data);
setLoading(false);
});
}, []);
function toggleSelected(productId: string, checked: boolean) {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(productId);
else next.delete(productId);
return next;
});
}
async function updateManaged(row: Row, managed: boolean) {
if (!row.variantId) return;
setSavingId(row.productId);
try {
await saveRow({ productId: row.productId, variantId: row.variantId, managed });
setRows((prev) =>
prev.map((r) => (r.productId === row.productId ? { ...r, managed } : r)),
);
} catch {
toast.error("No se pudo guardar", { description: row.title });
} finally {
setSavingId(null);
}
}
async function updateQuantity(row: Row, quantity: number) {
if (!row.variantId || Number.isNaN(quantity) || quantity < 0) return;
setSavingId(row.productId);
try {
await saveRow({ productId: row.productId, variantId: row.variantId, quantity });
setRows((prev) =>
prev.map((r) => (r.productId === row.productId ? { ...r, quantity } : r)),
);
toast.success("Stock actualizado", { description: row.title });
} catch {
toast.error("No se pudo guardar", { description: row.title });
} finally {
setSavingId(null);
}
}
async function bulkSetManaged(managed: boolean) {
const targets = rows.filter((r) => selected.has(r.productId) && r.variantId);
for (const row of targets) {
await updateManaged(row, managed);
}
setSelected(new Set());
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<div>
<Heading level="h2">Inventario</Heading>
<Text size="small" className="text-ui-fg-subtle">
Activa el seguimiento de stock y ajusta la cantidad disponible por producto.
</Text>
</div>
{selected.size > 0 && (
<div className="flex gap-2">
<Button size="small" variant="secondary" onClick={() => bulkSetManaged(true)}>
Activar seguimiento ({selected.size})
</Button>
<Button size="small" variant="secondary" onClick={() => bulkSetManaged(false)}>
Desactivar seguimiento
</Button>
</div>
)}
</div>
{loading ? (
<div className="px-6 py-8">
<Text className="text-ui-fg-subtle">Cargando…</Text>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-ui-fg-subtle">
<th className="w-10 px-6 py-3" />
<th className="px-3 py-3 font-medium">Producto</th>
<th className="px-3 py-3 font-medium">SKU</th>
<th className="px-3 py-3 font-medium">Trackear stock</th>
<th className="px-3 py-3 font-medium">Cantidad disponible</th>
<th className="px-3 py-3 font-medium" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.productId} className="border-b last:border-0">
<td className="px-6 py-3">
<Checkbox
checked={selected.has(row.productId)}
onCheckedChange={(checked) => toggleSelected(row.productId, checked === true)}
/>
</td>
<td className="flex items-center gap-3 px-3 py-3">
{row.thumbnail ? (
<img src={row.thumbnail} alt="" className="size-8 rounded object-cover" />
) : (
<div className="size-8 rounded bg-ui-bg-subtle" />
)}
<Text>{row.title}</Text>
</td>
<td className="px-3 py-3">
<Text className="text-ui-fg-subtle">{row.sku ?? "—"}</Text>
</td>
<td className="px-3 py-3">
<Switch
checked={row.managed}
disabled={savingId === row.productId}
onCheckedChange={(checked) => updateManaged(row, checked)}
/>
</td>
<td className="px-3 py-3">
<Input
type="number"
min={0}
defaultValue={row.quantity}
disabled={!row.managed || savingId === row.productId}
className="w-24"
onBlur={(event) => {
const value = Number(event.target.value);
if (value !== row.quantity) updateQuantity(row, value);
}}
/>
</td>
<td className="px-3 py-3">
{row.managed && row.quantity <= LOW_STOCK_THRESHOLD && (
<Badge color="red">Stock bajo</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Container>
);
};
export const config = defineRouteConfig({
label: "Inventario",
icon: Buildings,
});
export default InventoryPanel;
@@ -0,0 +1,152 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
import {
createInventoryItemsWorkflow,
updateInventoryLevelsWorkflow,
updateProductVariantsWorkflow,
} from "@medusajs/medusa/core-flows";
import { attachInventoryItemToVariantWorkflow } from "../../../workflows/attach-inventory-item-to-variant";
// Panel simplificado de inventario (pensado para un usuario no tecnico,
// no reemplaza la vista nativa de Medusa Admin -- ver ADR/memoria del
// bug de Firefox Android, tema aparte). Un producto = una variante en
// este catalogo real (confirmado en vivo contra /store/products antes
// de asumirlo), asi que la tabla trabaja a nivel de producto.
const PRODUCT_FIELDS = [
"id",
"title",
"thumbnail",
"status",
"variants.id",
"variants.sku",
"variants.manage_inventory",
"variants.inventory_items.inventory_item_id",
"variants.inventory_items.inventory.location_levels.id",
"variants.inventory_items.inventory.location_levels.location_id",
"variants.inventory_items.inventory.location_levels.stocked_quantity",
];
async function defaultLocationId(req: MedusaRequest): Promise<string | null> {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const { data: locations = [] } = await query.graph({
entity: "stock_location",
fields: ["id"],
});
return locations[0]?.id ?? null;
}
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const locationId = await defaultLocationId(req);
const { data: products = [] } = await query.graph({
entity: "product",
fields: PRODUCT_FIELDS,
});
const rows = products.map((product) => {
const variant = product.variants?.[0];
const inventoryItem = variant?.inventory_items?.[0];
const level = inventoryItem?.inventory?.location_levels?.find(
(candidate) => candidate?.location_id === locationId,
);
return {
productId: product.id,
variantId: variant?.id ?? null,
title: product.title,
thumbnail: product.thumbnail,
status: product.status,
sku: variant?.sku ?? null,
managed: variant?.manage_inventory ?? false,
quantity: level?.stocked_quantity ?? 0,
};
});
res.json({ rows, locationId });
}
type PanelUpdateBody = {
productId: string;
variantId: string;
managed?: boolean;
quantity?: number;
};
export async function POST(req: MedusaRequest<PanelUpdateBody>, res: MedusaResponse) {
const { productId, variantId, managed, quantity } = req.body;
if (!productId || !variantId) {
res.status(400).json({ error: "productId y variantId son obligatorios" });
return;
}
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const locationId = await defaultLocationId(req);
if (!locationId) {
res.status(400).json({ error: "No hay una bodega (stock location) configurada" });
return;
}
const { data: products = [] } = await query.graph({
entity: "product",
fields: PRODUCT_FIELDS,
filters: { id: productId },
});
const variant = products[0]?.variants?.find((v: { id: string }) => v.id === variantId);
const inventoryItem = variant?.inventory_items?.[0];
let inventoryItemId: string | undefined = inventoryItem?.inventory_item_id;
if (managed === true && !inventoryItemId) {
// Primera vez que se activa el seguimiento para esta variante -- no
// trae InventoryItem todavia (confirmado en vivo: togglear
// manage_inventory=true en una variante existente NO crea uno solo,
// a diferencia de crear un producto nuevo con manage_inventory
// desde el inicio). Se crea con el nivel de stock inicial y se
// vincula aparte.
const { result: items } = await createInventoryItemsWorkflow(req.scope).run({
input: {
items: [
{
title: variant?.sku ? undefined : products[0]?.title,
sku: variant?.sku ?? undefined,
location_levels: [{ location_id: locationId, stocked_quantity: quantity ?? 0 }],
},
],
},
});
inventoryItemId = items[0]?.id;
if (inventoryItemId) {
await attachInventoryItemToVariantWorkflow(req.scope).run({
input: { inventoryItemId, variantId },
});
}
await updateProductVariantsWorkflow(req.scope).run({
input: { product_variants: [{ id: variantId, manage_inventory: true }] },
});
} else {
if (typeof managed === "boolean") {
await updateProductVariantsWorkflow(req.scope).run({
input: { product_variants: [{ id: variantId, manage_inventory: managed }] },
});
}
if (typeof quantity === "number" && inventoryItemId) {
const level = inventoryItem?.inventory?.location_levels?.find(
(candidate) => candidate?.location_id === locationId,
);
await updateInventoryLevelsWorkflow(req.scope).run({
input: {
updates: [
{
id: level?.id,
inventory_item_id: inventoryItemId,
location_id: locationId,
stocked_quantity: quantity,
},
],
},
});
}
}
res.json({ ok: true });
}
@@ -0,0 +1,257 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { Modules } from "@medusajs/framework/utils";
import type {
IFulfillmentModuleService,
IProductModuleService,
ISalesChannelModuleService,
IStockLocationService,
} from "@medusajs/framework/types";
import {
createInventoryItemsWorkflow,
createProductsWorkflow,
uploadFilesWorkflow,
} from "@medusajs/medusa/core-flows";
import { csvRowsToObjects, parseCsv } from "../../../lib/csv";
// Importador masivo de productos via CSV -- para alguien que compra
// producto fisico en Amazon/eBay/etc. y quiere cargarlo rapido a la
// tienda, no para dropshipping automatizado (ver conversacion: se evaluo
// y se descarto raspar catalogos de terceros sin autorizacion). Cada
// fila se procesa independiente (una foto rota o categoria mal escrita
// no tumba el resto del archivo).
//
// Las fotos se descargan del origen y se re-alojan en el mismo
// MinIO/S3 que ya usa el resto del catalogo (uploadFilesWorkflow ->
// mismo File Module Provider de medusa-config.ts, ya corre en el NAS).
// El bucket (S3_BUCKET=ari-products) es plano -- el provider de S3 de
// Medusa descarta cualquier subcarpeta del filename (usa solo
// path.parse().name + ulid, confirmado leyendo
// @medusajs/file-s3/dist/services/s3-file.js), asi que no hay una
// carpeta separada real para lo importado -- queda mezclado con el
// resto de fotos del catalogo, mismo criterio de nombres unicos por
// ulid que ya usa todo el sitio.
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
const IMAGE_FETCH_TIMEOUT_MS = 15000;
const OPTION_TITLE = "Presentación";
const DEFAULT_VARIANT_VALUE = "Estandar";
type ImportRow = {
titulo: string;
descripcion: string;
precio: string;
categoria: string;
coleccion: string;
cantidad: string;
fotos: string;
};
type RowResult = {
row: number;
title: string;
ok: boolean;
error?: string;
};
// Medusa genera el handle por defecto solo en minusculas + espacios a
// guiones, sin quitar tildes/enies -- confirmado en vivo: 3 productos
// importados con titulo acentuado (ej. "niñas", "Lógicos") quedaron con
// handle como "...niñas..." y daban 404 real al entrar por
// shop.cruzcloud.net (el tunel/ingress publico rompe rutas con UTF-8
// no-ASCII en el path, aunque el mismo request funciona perfecto pegandole
// directo al servicio dentro del cluster). Se genera el handle nosotros
// mismos, ASCII-only, en vez de dejarselo al default de Medusa. El sufijo
// corto evita colisiones entre titulos parecidos/repetidos sin necesidad
// de revisar unicidad antes de crear.
function slugify(title: string, uniquePart: string): string {
const base = title
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80)
.replace(/-+$/g, "");
return `${base || "producto"}-${uniquePart}`;
}
function extensionFromContentType(contentType: string | null, url: string): string {
if (contentType?.includes("png")) return "png";
if (contentType?.includes("webp")) return "webp";
if (contentType?.includes("gif")) return "gif";
if (contentType?.includes("jpeg") || contentType?.includes("jpg")) return "jpg";
const match = url.match(/\.(jpe?g|png|webp|gif)(?:\?|$)/i);
return match ? match[1].toLowerCase().replace("jpeg", "jpg") : "jpg";
}
async function downloadImageAsBase64(url: string): Promise<{ content: string; mimeType: string; extension: string }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`No se pudo descargar la imagen (${response.status})`);
}
const contentType = response.headers.get("content-type");
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > MAX_IMAGE_BYTES) {
throw new Error(`Imagen supera ${MAX_IMAGE_BYTES / 1024 / 1024}MB`);
}
const extension = extensionFromContentType(contentType, url);
return {
content: buffer.toString("base64"),
mimeType: contentType?.split(";")[0] ?? `image/${extension === "jpg" ? "jpeg" : extension}`,
extension,
};
} finally {
clearTimeout(timeout);
}
}
export async function GET(req: MedusaRequest, res: MedusaResponse) {
// Plantilla descargable -- misma fuente de verdad que valida el POST,
// asi que las columnas nunca quedan desincronizadas entre lo que se
// ofrece descargar y lo que realmente se procesa.
const header = "titulo,descripcion,precio,categoria,coleccion,cantidad,fotos";
const example =
'"Muñeco Buzz Lightyear","Figura articulada de 30cm, edición coleccionista.",89900,"Figuras de accion","Toy Story",3,"https://ejemplo.com/foto1.jpg;https://ejemplo.com/foto2.jpg"';
const csv = `${header}\n${example}\n`;
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", 'attachment; filename="plantilla-importacion.csv"');
res.send(csv);
}
export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaResponse) {
const csvText = req.body?.csv;
if (!csvText || typeof csvText !== "string") {
res.status(400).json({ error: "Falta el contenido CSV" });
return;
}
const rows = csvRowsToObjects(parseCsv(csvText)) as unknown as ImportRow[];
if (rows.length === 0) {
res.status(400).json({ error: "El archivo no tiene filas de datos" });
return;
}
const stockLocationService = req.scope.resolve<IStockLocationService>(Modules.STOCK_LOCATION);
const fulfillmentService = req.scope.resolve<IFulfillmentModuleService>(Modules.FULFILLMENT);
const salesChannelService = req.scope.resolve<ISalesChannelModuleService>(Modules.SALES_CHANNEL);
const productService = req.scope.resolve<IProductModuleService>(Modules.PRODUCT);
const [locations, shippingProfiles, salesChannels, categories, collections] = await Promise.all([
stockLocationService.listStockLocations({}),
fulfillmentService.listShippingProfiles({}),
salesChannelService.listSalesChannels({}),
// select explicito -- sin el, name/title llegaban undefined (el
// default de estos module services no siempre trae todos los
// escalares, a diferencia de la capa HTTP del Admin API, confirmado
// en vivo: la ruta /admin/product-categories con fields=id,name si
// los trae completos).
productService.listProductCategories({}, { select: ["id", "name"] }),
productService.listProductCollections({}, { select: ["id", "title"] }),
]);
const locationId = locations[0]?.id;
const shippingProfileId = shippingProfiles.find((p) => p.type === "default")?.id ?? shippingProfiles[0]?.id;
const salesChannelId = salesChannels[0]?.id;
if (!locationId || !shippingProfileId || !salesChannelId) {
res.status(400).json({
error: "Faltan datos base de la tienda (bodega, perfil de envío o canal de venta)",
});
return;
}
const categoryByName = new Map(
categories.filter((c) => c.name).map((c) => [c.name.toLowerCase().trim(), c.id]),
);
const collectionByName = new Map(
collections.filter((c) => c.title).map((c) => [c.title.toLowerCase().trim(), c.id]),
);
const results: RowResult[] = [];
for (let index = 0; index < rows.length; index++) {
const rowNumber = index + 2; // +1 header, +1 base 1
const row = rows[index];
const title = row.titulo?.trim();
try {
if (!title) throw new Error("Falta el título");
const price = Number(row.precio);
if (!Number.isFinite(price) || price <= 0) throw new Error("Precio inválido");
const imageUrls = (row.fotos ?? "")
.split(";")
.map((u) => u.trim())
.filter(Boolean);
if (imageUrls.length === 0) throw new Error("Falta al menos una foto");
const downloaded = await Promise.all(imageUrls.map((url) => downloadImageAsBase64(url)));
const { result: uploadedFiles } = await uploadFilesWorkflow(req.scope).run({
input: {
files: downloaded.map((file, i) => ({
filename: `${Date.now()}-${index}-${i}.${file.extension}`,
mimeType: file.mimeType,
content: file.content,
access: "public" as const,
})),
},
});
const quantity = Number(row.cantidad);
const { result: inventoryItems } = await createInventoryItemsWorkflow(req.scope).run({
input: {
items: [
{
title,
location_levels: [
{ location_id: locationId, stocked_quantity: Number.isFinite(quantity) && quantity >= 0 ? quantity : 1 },
],
},
],
},
});
const inventoryItemId = inventoryItems[0]?.id;
if (!inventoryItemId) throw new Error("No se pudo crear el registro de inventario");
const categoryId = row.categoria ? categoryByName.get(row.categoria.toLowerCase().trim()) : undefined;
const collectionId = row.coleccion ? collectionByName.get(row.coleccion.toLowerCase().trim()) : undefined;
await createProductsWorkflow(req.scope).run({
input: {
products: [
{
title,
handle: slugify(title, `${Date.now().toString(36)}${index}`),
description: row.descripcion || undefined,
status: "published",
thumbnail: uploadedFiles[0]?.url,
images: uploadedFiles.map((f) => ({ url: f.url })),
category_ids: categoryId ? [categoryId] : undefined,
collection_id: collectionId,
shipping_profile_id: shippingProfileId,
sales_channels: [{ id: salesChannelId }],
options: [{ title: OPTION_TITLE, values: [DEFAULT_VARIANT_VALUE] }],
variants: [
{
title: DEFAULT_VARIANT_VALUE,
options: { [OPTION_TITLE]: DEFAULT_VARIANT_VALUE },
manage_inventory: true,
inventory_items: [{ inventory_item_id: inventoryItemId }],
prices: [{ amount: price, currency_code: "cop" }],
},
],
},
],
},
});
results.push({ row: rowNumber, title, ok: true });
} catch (error) {
results.push({ row: rowNumber, title: title || `Fila ${rowNumber}`, ok: false, error: (error as Error).message });
}
}
res.json({ results });
}
+70
View File
@@ -0,0 +1,70 @@
// Parser CSV minimo (RFC4180: campos entre comillas, comillas escapadas
// como "", comas y saltos de linea dentro de campos citados) -- se evita
// sumar una dependencia nueva (papaparse/csv-parse) para un formato que
// nosotros mismos controlamos via la plantilla descargable.
export function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let inQuotes = false;
let i = 0;
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
while (i < normalized.length) {
const char = normalized[i];
if (inQuotes) {
if (char === '"') {
if (normalized[i + 1] === '"') {
field += '"';
i += 2;
continue;
}
inQuotes = false;
i += 1;
continue;
}
field += char;
i += 1;
continue;
}
if (char === '"') {
inQuotes = true;
i += 1;
continue;
}
if (char === ",") {
row.push(field);
field = "";
i += 1;
continue;
}
if (char === "\n") {
row.push(field);
rows.push(row);
row = [];
field = "";
i += 1;
continue;
}
field += char;
i += 1;
}
if (field.length > 0 || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows.filter((r) => r.some((cell) => cell.trim().length > 0));
}
export function csvRowsToObjects(rows: string[][]): Record<string, string>[] {
const [header, ...body] = rows;
if (!header) return [];
const keys = header.map((h) => h.trim().toLowerCase());
return body.map((cols) => {
const obj: Record<string, string> = {};
keys.forEach((key, index) => {
obj[key] = (cols[index] ?? "").trim();
});
return obj;
});
}
@@ -0,0 +1,21 @@
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk";
import { attachInventoryItemToVariants } from "@medusajs/medusa/core-flows";
// attachInventoryItemToVariants es un step suelto (no un workflow
// exportado) -- confirmado leyendo el .js real instalado
// (@medusajs/core-flows/dist/inventory/steps/attach-inventory-items.js):
// internamente resuelve ContainerRegistrationKeys.LINK y crea el link
// Product<->Inventory. El campo "tag" del input es el variant_id (nombre
// generico del step, no documentado con ese detalle).
type Input = { inventoryItemId: string; variantId: string };
export const attachInventoryItemToVariantWorkflow = createWorkflow(
"attach-inventory-item-to-variant",
(input: Input) => {
return new WorkflowResponse(
attachInventoryItemToVariants([
{ inventoryItemId: input.inventoryItemId, tag: input.variantId },
]),
);
},
);
@@ -1,9 +1,18 @@
# ADR 0008 — Identidad visual "Navy & Coral" para ARI Shopping # ADR 0008 — Identidad visual "Navy & Coral" para ARI Shopping
**Estado:** aceptada (implementación en curso, por sub-fases) **Estado:** aceptada, con enmienda de paleta (ver §8) — sub-fases 1 a 4
**Fecha:** 2026-08-27 completas, sub-fase 5 parcial (ver §9)
**Fecha:** 2026-08-27 (enmendada 2026-09-06)
**Reemplaza a:** [ADR 0007](0007-identidad-visual-propia.md) (Opción 2, "Atelier cálido") **Reemplaza a:** [ADR 0007](0007-identidad-visual-propia.md) (Opción 2, "Atelier cálido")
> **Nota de vigencia:** la paleta descrita en §4 ("Navy & Coral":
> marino `#122844` / coral `#D85A30` / coral claro `#F0997B`) fue la
> dirección **aprobada e implementada en las sub-fases 1-3**, pero fue
> **reemplazada el mismo día** por una paleta azul/amarillo estilo
> Walmart (rama `fix/adr0008-walmart-palette-blue-yellow`, ver §8). Los
> valores reales en producción hoy están en §8, no en §4. Se deja §4
> intacto como registro histórico de la decisión original.
## 1. Contexto ## 1. Contexto
Este es el tercer intento de identidad visual propia para ARI Shopping, Este es el tercer intento de identidad visual propia para ARI Shopping,
@@ -168,3 +177,66 @@ definitivo; los alias se eliminan por completo al cerrar la sub-fase
`commerce-backend`, `platform-infra`, ni CI nuevo. `commerce-backend`, `platform-infra`, ni CI nuevo.
- `docs-portal` se actualiza con capturas/descripción del resultado - `docs-portal` se actualiza con capturas/descripción del resultado
final una vez cada sub-fase esté validada en producción, no antes. final una vez cada sub-fase esté validada en producción, no antes.
## 8. Enmienda 2026-08-27 tarde: pivote de paleta a azul/amarillo
Horas después de mergear la sub-fase 3 (product card + grid), se
mergeó `fix/adr0008-walmart-palette-blue-yellow`: la paleta "Navy &
Coral" de §4 se descartó en producción y se reemplazó por una paleta
azul/amarillo (mismo patrón "estética Walmart" ya usado como
referencia de UX en ADR 0007). Esta decisión **no quedó documentada
como enmienda en su momento** — se detecta y se registra aquí recién
el 2026-09-06, al auditar el estado real del código contra este
documento.
Valores reales en `globals.css` hoy (los nombres de token de §4 se
conservan, pero apuntan a estos valores, no a los de la tabla
original):
| Token | Valor real | Uso |
|---|---|---|
| `--color-ari-marino` | `#0055B8` (azul) | ancla: header, footer, fondo de hero, botón de búsqueda |
| `--color-ari-coral` | `#0055B8` (azul, mismo valor que marino) | CTA "Agregar al carrito", precio destacado |
| `--color-ari-coral-claro` | `#FFC220` (amarillo) | badges de oferta/urgencia, acento del isotipo, botón "yellow" del hero |
Nota: `ari-coral` y `ari-marino` son el mismo azul (`#0055B8`) — a
diferencia de la especificación original de §4, donde marino y coral
eran colores distintos. Cualquier lectura de este ADR para decisiones
de diseño futuras debe partir de esta tabla, no de §4.
El isotipo (mariposa de 4 elipses, §4) tampoco se implementó como se
describe: el logo vigente es `logo-ari-circular.svg` (rama
`adr0008-logo-circular-header`), un lockup circular distinto al
descrito en la tabla de coordenadas de §4. `logo-ari.svg` (rectangular,
el que sí sigue la spec original) quedó sin usarse en el header.
## 9. Estado real por sub-fase (auditado 2026-09-06)
Auditoría contra el código en `main`, no contra el plan de §6.
| # | Sub-fase | Estado | Evidencia |
|---|---|---|---|
| 1 | Tokens base + logo | ✅ completa (con la paleta enmendada en §8, no la de §4) | `globals.css`, `logo-ari.svg` inicial, luego reemplazado por circular en sub-fase posterior |
| 2 | Header + footer (incl. limpieza de §5) | ✅ completa | `navbar.tsx`, restos arcoíris (gradiente ámbar/lima, línea cian/violeta/fucsia de §5) eliminados |
| 3 | Product card + grid + badges | ✅ completa | `product-card.tsx` en tokens `ari-*` |
| 4 | Home (hero, tiles, secciones) | ✅ completa | `hero-carousel.tsx`, `category-tiles.tsx` (varias iteraciones: pastel → scrim → foto flotante → foto más grande, todas mergeadas) |
| 5 | Página de producto + drawers de carrito/wishlist | ⚠️ **parcial** | `product-purchase.tsx`/página de producto sí migraron (rama `adr0008-product-purchase-mobile-y-badge`). **`cart-drawer.tsx` y `wishlist-drawer.tsx` no tienen ningún token `ari-*` — nunca se tocaron.** No hay rama mergeada que los mencione. |
**Deuda abierta, no asumir resuelta:**
- Los alias de compatibilidad de §6 (`ari-ciruela`, `ari-cobre`,
`ari-carbon`, `ari-carbon-oscuro`, `ari-terracota-oscuro`,
`ari-hueso`) **siguen en uso real en `button.tsx`**
(`focus-visible:ring-ari-cobre/40`, `bg-ari-ciruela`,
`hover:border-ari-cobre`) — la condición de cierre de §6 ("los alias
se eliminan por completo al cerrar la sub-fase 5") no se cumplió.
Confirma que la sub-fase 5 no está cerrada, más allá de los drawers.
- Ramas mergeadas fuera del plan original de 5 sub-fases de §6, sin
enmienda que las registre: `adr0008-megamenu-mobile-overflow`,
`adr0008-club-ari-y-novedades`, `adr0008-club-ari-imagen-azul`. Son
trabajo real en producción, pero expanden el alcance de este ADR sin
quedar documentado como tal.
- `docs-portal` (este documento) es la primera actualización desde el
cierre de la sub-fase 3 — la condición de §7 ("se actualiza con
capturas/descripción... una vez cada sub-fase esté validada") no se
vino cumpliendo sub-fase por sub-fase como se planeó.
@@ -0,0 +1,14 @@
import { handleGoogleCallback } from "@/lib/headless/providers/medusa-customer";
export async function POST(request: Request) {
const { code, state } = await request.json();
if (!code || !state) {
return Response.json({ error: "Faltan parámetros de Google" }, { status: 400 });
}
try {
const customer = await handleGoogleCallback(code, state);
return Response.json(customer);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 400 });
}
}
@@ -0,0 +1,14 @@
import { getGoogleAuthUrl } from "@/lib/headless/providers/medusa-customer";
// Navegacion de pagina completa (no fetch/XHR) -- el boton "Continuar
// con Google" enlaza directo aca, el browser sigue el 302 hasta la
// pantalla real de consentimiento de Google.
export async function GET(request: Request) {
try {
const location = await getGoogleAuthUrl();
return Response.redirect(location);
} catch (error) {
const message = encodeURIComponent((error as Error).message);
return Response.redirect(new URL(`/?googleAuthError=${message}`, request.url));
}
}
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import { GoogleCallbackClient } from "@/components/account/google-callback-client";
export default function GoogleCallbackPage() {
return (
<Suspense>
<GoogleCallbackClient />
</Suspense>
);
}
@@ -65,7 +65,7 @@ spec:
memory: 64Mi memory: 64Mi
- name: migrations - name: migrations
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168 image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.222
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- node - node
@@ -86,7 +86,7 @@ spec:
containers: containers:
- name: app - name: app
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168 image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.222
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
envFrom: envFrom:
- configMapRef: - configMapRef:
@@ -10,6 +10,17 @@ import { useCustomerStore } from "@/store/customer-store";
type Mode = "login" | "register"; type Mode = "login" | "register";
function GoogleIcon() {
return (
<svg viewBox="0 0 48 48" className="size-4" aria-hidden="true">
<path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3c-1.6 4.7-6.1 8-11.3 8-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.1 8 3l6-6C34 5.1 29.3 3 24 3 12.4 3 3 12.4 3 24s9.4 21 21 21 21-9.4 21-21c0-1.4-.1-2.7-.4-3.5z" />
<path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.6 15.9 18.9 13 24 13c3.1 0 5.8 1.1 8 3l6-6C34 5.1 29.3 3 24 3 16.1 3 9.3 7.6 6.3 14.7z" />
<path fill="#4CAF50" d="M24 45c5.2 0 9.9-2 13.4-5.2l-6.2-5.2c-2 1.5-4.6 2.4-7.2 2.4-5.2 0-9.6-3.3-11.3-8l-6.5 5C9.2 40.3 16 45 24 45z" />
<path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.3-2.2 4.3-4.1 5.6l6.2 5.2C41.4 35.9 45 30.5 45 24c0-1.4-.1-2.7-.4-3.5z" />
</svg>
);
}
export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const { customer, hasHydrated, loading, error, login, register, logout } = useCustomerStore(); const { customer, hasHydrated, loading, error, login, register, logout } = useCustomerStore();
const [mode, setMode] = useState<Mode>("login"); const [mode, setMode] = useState<Mode>("login");
@@ -57,6 +68,14 @@ export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenCha
{loading ? "Un momento…" : mode === "login" ? "Iniciar sesión" : "Crear cuenta"} {loading ? "Un momento…" : mode === "login" ? "Iniciar sesión" : "Crear cuenta"}
</Button> </Button>
</form> </form>
<div className="mt-3 flex items-center gap-3 text-xs font-bold uppercase tracking-wide text-slate-400">
<span className="h-px flex-1 bg-slate-100" />o<span className="h-px flex-1 bg-slate-100" />
</div>
<Button asChild variant="outline" size="lg" className="mt-3 w-full">
<a href="/api/customer/google/start">
<GoogleIcon />Continuar con Google
</a>
</Button>
<button <button
type="button" type="button"
className="mt-3 text-sm font-bold text-ari-marino hover:underline" className="mt-3 text-sm font-bold text-ari-marino hover:underline"
@@ -0,0 +1,44 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useCustomerStore } from "@/store/customer-store";
export function GoogleCallbackClient() {
const router = useRouter();
const searchParams = useSearchParams();
const loginWithGoogle = useCustomerStore((state) => state.loginWithGoogle);
const [error, setError] = useState<string | null>(null);
const ran = useRef(false);
useEffect(() => {
// Guard contra el doble-mount de StrictMode -- el code de Google es
// de un solo uso, un segundo intento con el mismo code fallaria.
if (ran.current) return;
ran.current = true;
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!code || !state) {
setError("Faltan parámetros de Google en la URL de retorno.");
return;
}
loginWithGoogle(code, state).then((ok) => {
router.replace(ok ? "/" : "/?googleAuthError=1");
});
}, [searchParams, loginWithGoogle, router]);
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 text-center">
{error ? (
<>
<p className="text-lg font-black text-rose-600">No se pudo iniciar sesión con Google</p>
<p className="text-sm text-slate-600">{error}</p>
</>
) : (
<p className="text-sm font-bold text-slate-600">Conectando con Google…</p>
)}
</div>
);
}
@@ -7,5 +7,19 @@ import { useCartStore } from "@/store/cart-store";
export function MobileAddToCart({ product }: { product: Product }) { export function MobileAddToCart({ product }: { product: Product }) {
const addItem = useCartStore((state) => state.addItem); const addItem = useCartStore((state) => state.addItem);
return <div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur lg:hidden"><Button variant="primary" size="lg" className="w-full" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag />Añadir al carrito</Button></div>; const outOfStock = product.variants[0]?.available === false;
return (
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur lg:hidden">
<Button
variant="primary"
size="lg"
className="w-full"
disabled={outOfStock}
onClick={() => addItem(product, product.variants[0]?.id)}
>
<ShoppingBag />
{outOfStock ? "Agotado" : "Añadir al carrito"}
</Button>
</div>
);
} }
@@ -24,7 +24,9 @@ export function ProductCard({ product, priority = false }: { product: Product; p
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id); const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id);
const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0]; const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0];
const outOfStock = selectedVariant?.available === false;
const lowStock = const lowStock =
!outOfStock &&
selectedVariant?.inventoryQuantity !== null && selectedVariant?.inventoryQuantity !== null &&
selectedVariant?.inventoryQuantity !== undefined && selectedVariant?.inventoryQuantity !== undefined &&
selectedVariant.inventoryQuantity > 0 && selectedVariant.inventoryQuantity > 0 &&
@@ -36,7 +38,8 @@ export function ProductCard({ product, priority = false }: { product: Product; p
<article className="group flex h-full flex-col overflow-hidden rounded-lg border border-slate-100 bg-white shadow-lg shadow-ari-marino/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-ari-coral/10"> <article className="group flex h-full flex-col overflow-hidden rounded-lg border border-slate-100 bg-white shadow-lg shadow-ari-marino/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-ari-coral/10">
<div className="relative aspect-square overflow-hidden bg-white"> <div className="relative aspect-square overflow-hidden bg-white">
<Link href={`/product/${product.slug}`} prefetch={false}><Image src={product.thumbnail} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px" className="object-cover transition duration-500 group-hover:scale-[1.035]" /></Link> <Link href={`/product/${product.slug}`} prefetch={false}><Image src={product.thumbnail} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px" className="object-cover transition duration-500 group-hover:scale-[1.035]" /></Link>
<div className="absolute left-3 top-3 flex gap-2">{discountPercent && discountPercent > 0 && <Badge className="bg-ari-coral-claro text-ari-marino">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-ari-marino text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-ari-coral-claro text-ari-marino"><Sparkles className="mr-1 size-3" />Top</Badge>}</div> <div className="absolute left-3 top-3 flex gap-2">{outOfStock && <Badge className="bg-rose-600 text-white">Agotado</Badge>}{discountPercent && discountPercent > 0 && <Badge className="bg-ari-coral-claro text-ari-marino">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-ari-marino text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-ari-coral-claro text-ari-marino"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
{outOfStock && <div className="pointer-events-none absolute inset-0 bg-white/40" />}
<button type="button" aria-label={isFavorite ? "Quitar de favoritos" : "Agregar a favoritos"} aria-pressed={isFavorite} className={`absolute right-3 top-3 rounded-full p-2.5 shadow-lg backdrop-blur transition ${isFavorite ? "bg-ari-coral text-white" : "bg-white/95 text-slate-900 hover:text-ari-coral"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button> <button type="button" aria-label={isFavorite ? "Quitar de favoritos" : "Agregar a favoritos"} aria-pressed={isFavorite} className={`absolute right-3 top-3 rounded-full p-2.5 shadow-lg backdrop-blur transition ${isFavorite ? "bg-ari-coral text-white" : "bg-white/95 text-slate-900 hover:text-ari-coral"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button>
</div> </div>
<div className="flex flex-1 flex-col space-y-3 p-5"> <div className="flex flex-1 flex-col space-y-3 p-5">
@@ -67,12 +70,15 @@ export function ProductCard({ product, priority = false }: { product: Product; p
{lowStock !== null && ( {lowStock !== null && (
<span className="flex items-center gap-1.5 text-xs font-bold text-amber-600"><TriangleAlert className="size-3.5" />Quedan {lowStock}</span> <span className="flex items-center gap-1.5 text-xs font-bold text-amber-600"><TriangleAlert className="size-3.5" />Quedan {lowStock}</span>
)} )}
{outOfStock && (
<span className="flex items-center gap-1.5 text-xs font-bold text-rose-600"><TriangleAlert className="size-3.5" />Sin stock</span>
)}
<div className="mt-auto flex items-center justify-between gap-3 pt-1"> <div className="mt-auto flex items-center justify-between gap-3 pt-1">
<div className="flex flex-col"> <div className="flex flex-col">
{product.compareAtPrice && <span className="text-xs font-bold text-slate-400 line-through">{formatMoney(product.compareAtPrice, product.currency)}</span>} {product.compareAtPrice && <span className="text-xs font-bold text-slate-400 line-through">{formatMoney(product.compareAtPrice, product.currency)}</span>}
<strong className="font-display text-base font-bold text-ari-marino">{formatMoney(product.price, product.currency)}</strong> <strong className="font-display text-base font-bold text-ari-marino">{formatMoney(product.price, product.currency)}</strong>
</div> </div>
<Button variant="primary" size="sm" onClick={() => addItem(product, selectedVariantId)}><ShoppingBag className="size-4" />Agregar</Button> <Button variant="primary" size="sm" disabled={outOfStock} onClick={() => addItem(product, selectedVariantId)}><ShoppingBag className="size-4" />{outOfStock ? "Agotado" : "Agregar"}</Button>
</div> </div>
</div> </div>
</article> </article>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { CheckCircle2, ShoppingBag, TriangleAlert, Truck } from "lucide-react"; import { CheckCircle2, ShoppingBag, TriangleAlert, Truck, XCircle } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types"; import type { Product } from "@/lib/headless/types";
@@ -11,12 +11,68 @@ export function ProductPurchase({ product }: { product: Product }) {
const [variantId, setVariantId] = useState(product.variants[0]?.id); const [variantId, setVariantId] = useState(product.variants[0]?.id);
const addItem = useCartStore((state) => state.addItem); const addItem = useCartStore((state) => state.addItem);
const selectedVariant = product.variants.find((variant) => variant.id === variantId) ?? product.variants[0]; const selectedVariant = product.variants.find((variant) => variant.id === variantId) ?? product.variants[0];
const outOfStock = selectedVariant?.available === false;
const lowStock = const lowStock =
!outOfStock &&
selectedVariant?.inventoryQuantity !== null && selectedVariant?.inventoryQuantity !== null &&
selectedVariant?.inventoryQuantity !== undefined && selectedVariant?.inventoryQuantity !== undefined &&
selectedVariant.inventoryQuantity > 0 && selectedVariant.inventoryQuantity > 0 &&
selectedVariant.inventoryQuantity <= LOW_STOCK_THRESHOLD selectedVariant.inventoryQuantity <= LOW_STOCK_THRESHOLD
? selectedVariant.inventoryQuantity ? selectedVariant.inventoryQuantity
: null; : null;
return <div><div className="mb-5 flex flex-wrap gap-2">{product.variants.map((variant) => <button key={variant.id} onClick={() => setVariantId(variant.id)} className={`rounded-full border px-4 py-2 text-sm font-bold ${variantId === variant.id ? "border-ari-marino bg-ari-marino text-white" : "border-slate-200"}`}>{variant.value}</button>)}</div><div className="mb-6 flex items-end gap-3"><strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}</div>{/* oculto en mobile: MobileAddToCart (barra fija abajo, lg:hidden) ya cubre esa misma accion ahi -- sin el hidden aqui, ambos se mostraban a la vez. */}<Button variant="primary" size="lg" className="hidden w-full lg:inline-flex" onClick={() => addItem(product, variantId)}><ShoppingBag />Añadir al carrito</Button><div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm">{lowStock !== null ? <span className="flex items-center gap-2 font-bold text-amber-600"><TriangleAlert className="size-4" />Quedan {lowStock} unidades</span> : <span className="flex items-center gap-2 font-bold"><CheckCircle2 className="size-4 text-emerald-500" />Disponibilidad por confirmar</span>}<span className="flex items-center gap-2 font-bold"><Truck className="size-4 text-cyan-600" />Envíos nacionales</span></div></div>;
return (
<div>
<div className="mb-5 flex flex-wrap gap-2">
{product.variants.map((variant) => (
<button
key={variant.id}
onClick={() => setVariantId(variant.id)}
className={`rounded-full border px-4 py-2 text-sm font-bold ${
variantId === variant.id ? "border-ari-marino bg-ari-marino text-white" : "border-slate-200"
} ${variant.available === false ? "opacity-40" : ""}`}
>
{variant.value}
</button>
))}
</div>
<div className="mb-6 flex items-end gap-3">
<strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>
{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}
</div>
{/* oculto en mobile: MobileAddToCart (barra fija abajo, lg:hidden) ya cubre esa misma accion ahi -- sin el hidden aqui, ambos se mostraban a la vez. */}
<Button
variant="primary"
size="lg"
className="hidden w-full lg:inline-flex"
disabled={outOfStock}
onClick={() => addItem(product, variantId)}
>
<ShoppingBag />
{outOfStock ? "Agotado" : "Añadir al carrito"}
</Button>
<div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm">
{outOfStock ? (
<span className="flex items-center gap-2 font-bold text-rose-600">
<XCircle className="size-4" />
Agotado
</span>
) : lowStock !== null ? (
<span className="flex items-center gap-2 font-bold text-amber-600">
<TriangleAlert className="size-4" />
Quedan {lowStock} unidades
</span>
) : (
<span className="flex items-center gap-2 font-bold">
<CheckCircle2 className="size-4 text-emerald-500" />
Disponibilidad por confirmar
</span>
)}
<span className="flex items-center gap-2 font-bold">
<Truck className="size-4 text-cyan-600" />
Envíos nacionales
</span>
</div>
</div>
);
} }
@@ -25,7 +25,7 @@ spec:
runAsUser: 1001 runAsUser: 1001
containers: containers:
- name: app - name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.213 image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.219
env: env:
- name: HEADLESS_PROVIDER - name: HEADLESS_PROVIDER
value: medusa value: medusa
@@ -87,6 +87,56 @@ export async function loginCustomer(email: string, password: string): Promise<Cu
return getCurrentCustomer(data.token as string); return getCurrentCustomer(data.token as string);
} }
// Flujo Google verificado en vivo (2026-08-29) contra el pod real, no
// solo contra la doc: GET /auth/customer/google funciona igual que el
// POST documentado (sin body, no hace falta callback_url -- ya viene de
// GOOGLE_CALLBACK_URL en el modulo); la respuesta no trae Set-Cookie, o
// sea que el "state" que Google reenvia en el callback es autocontenido
// (firmado), no depende de sesion de servidor -- confirmado con un
// callback con code/state inventados: 401 "No state provided, or
// session expired" (mismo mensaje que con state ausente, no un error de
// sesion real). auth/token/refresh confirmado que exige Bearer valido
// (401 "Unauthorized" con uno invalido, no 404).
function decodeJwtPayload(token: string): Record<string, unknown> {
const payload = token.split(".")[1] ?? "";
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const json = Buffer.from(base64, "base64").toString("utf-8");
return JSON.parse(json || "{}");
}
export async function getGoogleAuthUrl(): Promise<string> {
const data = await medusaAuthFetch("/auth/customer/google");
return data.location as string;
}
export async function handleGoogleCallback(code: string, state: string): Promise<CustomerSummary> {
const query = new URLSearchParams({ code, state }).toString();
const data = await medusaAuthFetch(`/auth/customer/google/callback?${query}`, { method: "POST" });
const initialToken = data.token as string;
const payload = decodeJwtPayload(initialToken);
const actorId = payload.actor_id as string | undefined;
if (!actorId) {
// Primer login con Google -- todavia no existe el customer real.
const userMetadata = (payload.user_metadata as Record<string, unknown> | undefined) ?? {};
const email = userMetadata.email as string;
const firstName = (userMetadata.given_name as string) ?? (userMetadata.name as string) ?? email.split("@")[0];
const lastName = (userMetadata.family_name as string) ?? "";
await medusaAuthFetch("/store/customers", {
method: "POST",
token: initialToken,
body: { email, first_name: firstName, last_name: lastName },
});
}
// El token del callback (o el de recien crear el customer) todavia no
// sirve como sesion final -- hay que refrescarlo, tanto para usuarios
// nuevos como para los que ya existian (confirmado contra la doc: los
// dos casos convergen en el mismo paso de refresh).
const refreshed = await medusaAuthFetch("/auth/token/refresh", { method: "POST", token: initialToken });
return getCurrentCustomer(refreshed.token as string);
}
export async function registerCustomer( export async function registerCustomer(
email: string, email: string,
password: string, password: string,
@@ -23,6 +23,7 @@ interface CustomerState {
setHasHydrated: (value: boolean) => void; setHasHydrated: (value: boolean) => void;
register: (email: string, password: string, firstName: string, lastName: string) => Promise<boolean>; register: (email: string, password: string, firstName: string, lastName: string) => Promise<boolean>;
login: (email: string, password: string) => Promise<boolean>; login: (email: string, password: string) => Promise<boolean>;
loginWithGoogle: (code: string, state: string) => Promise<boolean>;
logout: () => void; logout: () => void;
syncCustomer: () => Promise<void>; syncCustomer: () => Promise<void>;
} }
@@ -77,6 +78,17 @@ export const useCustomerStore = create<CustomerState>()(
return true; return true;
}, },
loginWithGoogle: async (code, state) => {
set({ loading: true, error: null });
const result = await postJson("/api/customer/google/callback", { code, state });
if (result.error) {
set({ loading: false, error: result.error });
return false;
}
set({ loading: false, token: result.token, customer: toCustomer(result) });
return true;
},
logout: () => set({ token: null, customer: null, error: null }), logout: () => set({ token: null, customer: null, error: null }),
// El token guardado en localStorage puede llevar dias o haber sido // El token guardado en localStorage puede llevar dias o haber sido