Compare commits

..
Author SHA1 Message Date
gitea-actions f3f6fb7166 chore(gitops): deploy Medusa v1.0.287 [skip ci] 2026-09-27 13:04:43 +00:00
devops 619228168d Merge branch 'feat/resenas-productos'
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 1m21s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 38m24s
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 49s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 33s
2026-09-27 12:23:49 +00:00
devops 7f0c1571d9 Merge branch 'feat/franquicias-personajes' 2026-09-27 12:23:49 +00:00
devopsandClaude Opus 5.5 cfd9a7ec96 feat(reseñas): reseñas de productos de compradores verificados, moderadas
Backend (commerce-backend):
- Módulo product-review (tabla nueva product_review; migración generada con
  medusa db:generate, no toca tablas existentes).
- GET /store/reviews?product_id (solo aprobadas), /store/reviews/summary,
  GET /store/reviews/me y POST /store/reviews con auth de cliente: exige que
  el pedido sea del cliente, no esté cancelado y contenga el producto
  ("Compra verificada"); 1 reseña por cliente y producto; nace "pending".
- /admin/reviews (+ /:id POST status, DELETE) y página "Reseñas" en el admin
  para aprobar/rechazar/borrar.

Storefront (ecommerce):
- /cuenta/pedidos ("Mis pedidos"): historial real + diálogo "Califica tu
  compra" (radiogroup accesible, flechas de teclado).
- Ficha: sección de reseñas con "Compra verificada" y estrellas junto al
  título; tarjetas: promedio y cantidad (una llamada /api/reviews/summary).
- Accesos: "Mis pedidos y reseñas" en la cuenta y en Ayuda.

Validado en entorno descartable (Postgres/Redis/Meilisearch/Medusa propios):
E2E de API 20/20 (incluye pedido interno por draft order, cancelación y
borrado), E2E de tienda con navegador 15/15, admin "Reseñas" verificado en
build de producción. Revisado por frontend-critic (contraste de estrellas
amber-600, patrón radiogroup).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 07:57:20 +00:00
devopsandClaude Opus 5.5 472c12ec0a feat(storefront): franquicias, sagas y personajes (Disney > Toy Story > Rex)
Taxonomía desde tags de Medusa con prefijo (franquicia:/saga:/personaje:),
muchos-a-muchos (ej. "Minnie y Mickey" tiene dos personajes). Pixar va
dentro de Disney.
- lib/taxonomy.ts: helpers + árbol franquicia > saga > personaje.
- Navbar: "Personajes" pasa a mega panel por franquicia (con totales,
  sagas y personajes); el menú lateral mobile muestra franquicias y sagas.
  Fallback a colecciones si el provider no trae tags.
- Catálogo: filtros "Franquicia" y "Personaje" (acotado a la franquicia),
  chip removible de "Saga", ?franquicia/?saga/?personaje en la URL, título
  según la taxonomía de la URL, búsqueda normalizada ("spiderman" encuentra
  "Spider-Man"). Conteos client-side (no son facetas de Meilisearch).
- allowedCategories memoizado (elimina warnings exhaustive-deps).

Revisado por frontend-critic (contraste de personajes /80, H1 solo desde la
URL, Escape devuelve el foco al trigger) + Playwright: check-taxonomy
17/17 + extras, shot-navbar sin regresiones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 07:06:32 +00:00
devops 271ea30f44 chore: promote v1.0.286 to production [skip ci] 2026-09-27 05:38:30 +00:00
devops d1fa58bf24 chore: release v1.0.286 [skip ci] 2026-09-27 05:38:29 +00:00
devops 8145e90441 Merge branch 'feat/navbar-desktop-categorias'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 1m1s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 14m58s
2026-09-27 05:22:21 +00:00
devopsandClaude Opus 5.5 e6f031a366 feat(storefront): barra de navegación desktop con categorías, personajes, ayuda y ubicación
- Desktop xl+: las 6 categorías raíz directo en la barra con desplegable
  de subcategorías; lg-xl mantiene "Departamentos"; "Personajes"
  (colecciones) y "Ayuda" a la derecha; hamburguesa solo en mobile.
- "Enviar a <nombre> · <ciudad>" (location-store persistido) que precarga
  departamento/ciudad en el checkout; "Hola X / Mi cuenta" y "Cambios y
  devoluciones" en xl+.
- Header compacto al hacer scroll en lg+ (histéresis 160/40px).
- Fix de menús (también en DepartmentsMegaMenu, ya presente en prod): el
  click de mouse ya no cierra el menú recién abierto por hover
  (keepOpenAfterHover) y el hover solo reacciona a pointerType "mouse",
  así el mouseleave sintético de touch no autocierra en tablets.

Revisado por el subagente frontend-critic (3 hallazgos, todos corregidos y
re-verificados) + Playwright contra build de producción local: sin solapes
a 1024/1280/1440/1920, click real, touch, mobile 390/320 sin cambios.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 05:22:21 +00:00
devops 503f56977d chore: promote v1.0.285 to production [skip ci] 2026-09-27 04:20:53 +00:00
devops f8304633c1 chore: release v1.0.285 [skip ci] 2026-09-27 04:20:44 +00:00
devops a25cad6da3 Merge branch 'fix/product-card-galeria-object-contain'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 18m54s
2026-09-27 04:00:45 +00:00
devopsandClaude Opus 5.5 cbdb44c7dc fix(storefront): galería de tarjetas sin recortes y sin sobrecargar el optimizador
- object-contain en vez de object-cover: las fotos secundarias no son
  cuadradas (Buzz 933x1500, Tristeza 896x1052) y cover les cortaba
  cabeza/pies. Mismo criterio que product-gallery.tsx.
- unoptimized desde la 2da imagen: pasar las ~58 imagenes de las galerias
  por el optimizador de Next dejo un pod de prod OOMKilled (768Mi) ante una
  carga en rafaga. Originales ~165 KB promedio, lazy; la 1ra imagen sigue
  optimizada como antes de la galeria.

Validado con build de produccion local y fotos reales (Playwright: fit
contain medido, fuentes optimizada/directa, suite de galeria completa).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 04:00:44 +00:00
devops 09c756942b chore: promote v1.0.284 to production [skip ci] 2026-09-27 02:17:10 +00:00
devops a9ef0ed00a chore: release v1.0.284 [skip ci] 2026-09-27 02:17:08 +00:00
devops da7f316eb1 Merge branch 'feat/product-card-galeria-swipe'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 2m4s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 13m27s
2026-09-27 01:37:26 +00:00
devopsandClaude Opus 5.5 123cbbd87a feat(storefront): galería deslizable en las tarjetas de producto del listado
Permite ver todas las imágenes de un producto desde el catálogo y los
carruseles sin entrar a la ficha: swipe con scroll-snap nativo en
móvil, flechas al pasar el mouse en escritorio (pointer-fine) y puntos
de posición. Cada imagen sigue enlazando a la ficha; productos con una
sola imagen no muestran controles. overscroll-behavior-x: contain evita
que el swipe de imágenes mueva el carrusel de productos del inicio.

Validado antes del merge con build de producción local (datos mock) y
Playwright con eventos táctiles reales a 390/320 px y 1280 px.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 01:37:25 +00:00
gitea-actions 496ad982f3 chore(gitops): deploy Docs Portal v1.0.283 [skip ci] 2026-09-27 00:31:40 +00:00
devops fa183a7115 Merge branch 'docs/tmux-host-ipv6-confirmado-2026-09-26'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 1m8s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 13m45s
2026-09-27 00:16:38 +00:00
devopsandClaude Opus 5.5 4146245ae9 docs(playbooks): IPv6 confirmado tras segundo reinicio, cierre de pendientes
El segundo reinicio real del NAS confirmo que /etc/sysctl.d/99-disable-ipv6.conf
se aplica al arrancar (disable_ipv6=1 sin sysctl -p manual). Se cierran
todos los pendientes del playbook y se reescribe la lista sin sintaxis de
tasklist/tachado, que el portal renderizaba literal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 00:16:37 +00:00
gitea-actions a38edfecc8 chore(gitops): deploy Docs Portal v1.0.282 [skip ci] 2026-09-26 19:33:01 +00:00
devops 2566e06abd Merge branch 'docs/tmux-host-ipv6-reinicio-2026-09-26'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 42s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 9m35s
2026-09-26 19:22:40 +00:00
devopsandClaude Opus 5.5 c7ac1b52be docs(playbooks): resultado del reinicio real, IPv6 via /etc/sysctl.d en ZimaOS
La prueba de reinicio del NAS (2026-09-26) mostro que /etc/sysctl.conf
persiste pero ZimaOS no lo aplica al arrancar (systemd-sysctl solo lee
/etc/sysctl.d/*.conf, sin symlink 99-sysctl.conf). Se documenta la causa,
el fix con /etc/sysctl.d/99-disable-ipv6.conf, como aplicarlo en caliente
con un contenedor privilegiado de un solo uso, y se cierran los pendientes
ya resueltos (commit de entrypoint.sh, limpieza de etiquetas).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-26 19:22:39 +00:00
gitea-actions 2e7a3f3d24 chore(gitops): deploy Docs Portal v1.0.281 [skip ci] 2026-09-26 14:51:18 +00:00
devops d9c2037c59 Merge branch 'docs/incidente-tmux-host-2026-09-25'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 1m15s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 14m51s
2026-09-26 12:39:31 +00:00
devopsandClaude Opus 5.5 7549dc86d2 docs(playbooks): incidente cadena de 4 fallos en tmux-host (2026-09-25/26)
Documenta la cadena de causas encontradas tras eliminar tmux-host por
"no toma las sesiones remotas": volumenes de auth faltantes al recrear,
set -e matando al supervisor del healthcheck, IPv6 roto en el NAS
(timeout a Anthropic) y, como causa original, el token OAuth vencido.
Agrega la fila de expiracion OAuth a "Que sobrevive a que" en
sesion-tmux-persistente.md con enlace cruzado al playbook.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-26 12:39:30 +00:00
gitea-actions 8e0d6abb82 chore(gitops): deploy Docs Portal v1.0.280 [skip ci] 2026-09-21 14:10:53 +00:00
devopsandClaude Sonnet 5 d98ec9a488 Merge branch 'docs/adr-0013-iniciativas-clientes-2026-09-21'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 8m48s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-21 14:01:30 +00:00
devopsandClaude Sonnet 5 03cfb203eb docs(adr-0013): iniciativas de clientes (Apps Script + AppSheet), fase prioritaria
Nueva fase, separada y con prioridad sobre las Fases 2-4 del ADR 0012:
practica de Apps Script (via clasp, sin MCP -- se verifico que no existe
uno oficial de Google) y AppSheet (documentado desde afuera, sin export
de codigo posible, verificado contra la documentacion oficial) para
iniciativas reales de la empresa con cuenta personal, sin datos ni
credenciales de cliente en ningun repo/doc. Deja explicito lo que queda
en pausa mientras esta fase este activa: swap de tmux-host, Fase 4 del
ADR 0012 (GKE), prueba del skill frontend-design.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-21 14:01:17 +00:00
gitea-actions 4553967bec chore(gitops): deploy Docs Portal v1.0.279 [skip ci] 2026-09-21 04:26:38 +00:00
devopsandClaude Sonnet 5 059597346d docs(adr-0012): documentar pivot de Fase 1 a Connectors nativos de claude.ai
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 36s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 9m20s
El MCP self-hosted (taylorwilsdon/google_workspace_mcp) quedo abandonado:
limite real en FastMCP's OAuthProxy, no error de configuracion -
redirect_uri se arma desde base_url (no desde GOOGLE_OAUTH_REDIRECT_URI),
y base_url tambien alimenta issuer_url, que mcp/server/auth/routes.py
exige HTTPS-o-localhost sin bypass. Documenta la causa raiz confirmada
leyendo el codigo fuente instalado, la decision del usuario de usar
Connectors nativos en su lugar, y la verificacion en vivo (3 calendarios
reales, razonamiento de fechas correcto).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-21 04:16:19 +00:00
gitea-actions b55766847f chore(gitops): deploy Docs Portal v1.0.278 [skip ci] 2026-09-20 23:17:55 +00:00
devops 848bac0b2d Merge branch 'docs/plugins-y-subagentes-2026-09-20'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 57s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 11m58s
2026-09-20 23:04:41 +00:00
devops 8107f9b6c7 docs(arquitectura): documentar plugins y subagentes instalados en sesion lab 2026-09-20 23:04:20 +00:00
gitea-actions d82f4df83a chore(gitops): deploy Docs Portal v1.0.277 [skip ci] 2026-09-20 21:11:43 +00:00
devops 55eb9d8051 Merge branch 'docs/adr-0012-practica-multicloud-2026-09-20'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 37s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 7m47s
2026-09-20 21:03:22 +00:00
devops fb89c26919 docs(adr-0012): practica multicloud GCP/Azure en paralelo al lab ZimaOS 2026-09-20 21:03:02 +00:00
gitea-actions c245f3b67a chore(gitops): deploy Docs Portal v1.0.276 [skip ci] 2026-09-20 20:42:06 +00:00
devops 149f743975 Merge branch 'docs/adr-0010-cierre-zoom-imagenes-manuales-2026-09-20'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 37s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 9m16s
2026-09-20 20:32:18 +00:00
devops cc79d3b089 docs(adr-0010): cerrar bug de zoom con imagenes manuales, url() sin comillas 2026-09-20 20:31:58 +00:00
devops 5bcacdf09d chore: promote v1.0.275 to production [skip ci] 2026-09-20 20:22:40 +00:00
devops 3e5e18c14f chore: release v1.0.275 [skip ci] 2026-09-20 20:22:36 +00:00
devops e75a11d554 Merge branch 'fix/gallery-zoom-url-spaces-2026-09-20'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 39s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 13m30s
2026-09-20 20:07:58 +00:00
devops 0fd05dab2a fix(ecommerce): comillas en url() del zoom, rompia con espacios/parentesis en el nombre de archivo 2026-09-20 20:07:32 +00:00
gitea-actions 5fe18ce3d4 chore(gitops): deploy Docs Portal v1.0.274 [skip ci] 2026-09-20 19:25:20 +00:00
devops 2318e1da7d Merge branch 'docs/adr-0010-cierre-bugs-mobile-imagenes-2026-09-20'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 6m18s
2026-09-20 19:18:22 +00:00
47 changed files with 3085 additions and 78 deletions

No files matched your search

@@ -133,6 +133,11 @@ module.exports = defineConfig({
},
modules: [
// Reseñas de productos (compradores verificados + moderacion del
// dueño): src/modules/product-review, rutas en src/api/*/reviews.
{
resolve: "./src/modules/product-review",
},
// ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto
// un modulo de fulfillment con el provider manual_manual
// (confirmado leyendo @medusajs/utils/dist/common/define-config.js
@@ -0,0 +1,140 @@
import { defineRouteConfig } from "@medusajs/admin-sdk";
import { Star } from "@medusajs/icons";
import { Badge, Button, Container, Heading, Text, toast } from "@medusajs/ui";
import { useCallback, useEffect, useState } from "react";
import { sdk } from "../../lib/sdk";
// Moderacion de reseñas: todas nacen "pendiente" (solo compradores
// verificados pueden dejarlas, ver src/api/store/reviews/route.ts) y
// recien se publican en la tienda cuando se aprueban aca.
type Status = "pending" | "approved" | "rejected";
type Review = {
id: string;
product_title: string;
author_name: string;
rating: number;
title: string | null;
content: string;
status: Status;
order_id: string;
created_at: string;
};
const TABS: { status: Status; label: string }[] = [
{ status: "pending", label: "Pendientes" },
{ status: "approved", label: "Publicadas" },
{ status: "rejected", label: "Rechazadas" },
];
const BADGE: Record<Status, "orange" | "green" | "red"> = { pending: "orange", approved: "green", rejected: "red" };
const STATUS_LABEL: Record<Status, string> = { pending: "Pendiente", approved: "Publicada", rejected: "Rechazada" };
function stars(rating: number): string {
return "★".repeat(rating) + "☆".repeat(5 - rating);
}
const ReviewsPage = () => {
const [tab, setTab] = useState<Status>("pending");
const [reviews, setReviews] = useState<Review[]>([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const load = useCallback(async (status: Status) => {
setLoading(true);
const data = await sdk.client.fetch<{ reviews: Review[] }>(`/admin/reviews?status=${status}`);
setReviews(data.reviews);
setLoading(false);
}, []);
useEffect(() => {
load(tab);
}, [load, tab]);
async function setStatus(review: Review, status: Status) {
setBusyId(review.id);
try {
await sdk.client.fetch(`/admin/reviews/${review.id}`, { method: "post", body: { status } });
toast.success(status === "approved" ? "Reseña publicada en la tienda" : status === "rejected" ? "Reseña rechazada" : "Reseña devuelta a pendientes");
await load(tab);
} catch (error) {
toast.error((error as Error).message);
} finally {
setBusyId(null);
}
}
async function remove(review: Review) {
if (!window.confirm(`¿Borrar la reseña de ${review.author_name}? No se puede deshacer.`)) return;
setBusyId(review.id);
try {
await sdk.client.fetch(`/admin/reviews/${review.id}`, { method: "delete" });
toast.success("Reseña borrada");
await load(tab);
} catch (error) {
toast.error((error as Error).message);
} finally {
setBusyId(null);
}
}
return (
<Container className="divide-y p-0">
<div className="flex flex-wrap items-center justify-between gap-3 px-6 py-4">
<div>
<Heading level="h2">Reseñas</Heading>
<Text size="small" className="text-ui-fg-subtle">
Solo compradores verificados pueden reseñar. Nada se publica sin tu aprobación.
</Text>
</div>
<div className="flex gap-2">
{TABS.map((item) => (
<Button key={item.status} size="small" variant={tab === item.status ? "primary" : "secondary"} onClick={() => setTab(item.status)}>
{item.label}
</Button>
))}
</div>
</div>
{loading ? (
<div className="px-6 py-8"><Text>Cargando…</Text></div>
) : reviews.length === 0 ? (
<div className="px-6 py-8"><Text className="text-ui-fg-subtle">No hay reseñas en esta pestaña.</Text></div>
) : (
reviews.map((review) => (
<div key={review.id} className="flex flex-col gap-3 px-6 py-4 md:flex-row md:items-start md:justify-between">
<div className="max-w-2xl">
<div className="flex flex-wrap items-center gap-2">
<Text weight="plus">{review.product_title}</Text>
<Badge color={BADGE[review.status]} size="2xsmall">{STATUS_LABEL[review.status]}</Badge>
</div>
<Text size="small" className="mt-1 text-ui-tag-orange-icon" aria-label={`${review.rating} de 5 estrellas`}>{stars(review.rating)}</Text>
{review.title && <Text weight="plus" className="mt-1">{review.title}</Text>}
<Text className="mt-1 whitespace-pre-line">{review.content}</Text>
<Text size="small" className="mt-2 text-ui-fg-subtle">
{review.author_name} · {new Date(review.created_at).toLocaleDateString("es-CO")} · pedido {review.order_id}
</Text>
</div>
<div className="flex shrink-0 gap-2">
{review.status !== "approved" && (
<Button size="small" isLoading={busyId === review.id} onClick={() => setStatus(review, "approved")}>Aprobar</Button>
)}
{review.status !== "rejected" && (
<Button size="small" variant="secondary" disabled={busyId === review.id} onClick={() => setStatus(review, "rejected")}>Rechazar</Button>
)}
<Button size="small" variant="danger" disabled={busyId === review.id} onClick={() => remove(review)}>Borrar</Button>
</div>
</div>
))
)}
</Container>
);
};
export const config = defineRouteConfig({
label: "Reseñas",
icon: Star,
});
export default ReviewsPage;
@@ -0,0 +1,24 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { MedusaError } from "@medusajs/framework/utils";
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
import type ProductReviewModuleService from "../../../../modules/product-review/service";
const STATUSES = ["pending", "approved", "rejected"] as const;
// POST /admin/reviews/:id { status } -- aprobar / rechazar / volver a
// pendiente. DELETE /admin/reviews/:id -- borrar (ej. reseñas de prueba).
export async function POST(req: MedusaRequest<{ status?: string }>, res: MedusaResponse) {
const status = req.body?.status;
if (!status || !STATUSES.includes(status as (typeof STATUSES)[number])) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, `status debe ser uno de: ${STATUSES.join(", ")}`);
}
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const review = await reviews.updateProductReviews({ id: req.params.id, status: status as (typeof STATUSES)[number] });
res.json({ review });
}
export async function DELETE(req: MedusaRequest, res: MedusaResponse) {
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
await reviews.deleteProductReviews(req.params.id);
res.json({ id: req.params.id, deleted: true });
}
@@ -0,0 +1,25 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
import { PRODUCT_REVIEW_MODULE } from "../../../modules/product-review";
import type ProductReviewModuleService from "../../../modules/product-review/service";
// GET /admin/reviews?status=pending -- cola de moderacion para la
// pagina "Reseñas" del admin, con el titulo del producto resuelto.
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const status = typeof req.query.status === "string" ? req.query.status : undefined;
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const list = await reviews.listProductReviews(status ? { status: status as "pending" | "approved" | "rejected" } : {}, {
order: { created_at: "DESC" },
take: 200,
});
const productIds = [...new Set(list.map((review) => review.product_id))];
const titles: Record<string, string> = {};
if (productIds.length) {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const { data: products } = await query.graph({ entity: "product", fields: ["id", "title"], filters: { id: productIds } });
for (const product of products) titles[product.id] = product.title;
}
res.json({ reviews: list.map((review) => ({ ...review, product_title: titles[review.product_id] ?? review.product_id })) });
}
@@ -1,4 +1,4 @@
import { defineMiddlewares } from "@medusajs/medusa"
import { authenticate, defineMiddlewares } from "@medusajs/framework/http"
// GET /store/products descarta en silencio la relacion `categories`
// aunque `/admin/products` si la expone -- confirmado en vivo en la
@@ -9,6 +9,20 @@ import { defineMiddlewares } from "@medusajs/medusa"
// https://docs.medusajs.com/learn/fundamentals/api-routes/retrieve-custom-links
export default defineMiddlewares({
routes: [
// Reseñas: crear una y ver las propias exige cliente autenticado --
// la verificacion de compra usa auth_context.actor_id (ver
// src/api/store/reviews/route.ts). GET /store/reviews y /summary
// siguen publicos.
{
matcher: "/store/reviews",
method: ["POST"],
middlewares: [authenticate("customer", ["session", "bearer"])],
},
{
matcher: "/store/reviews/me",
method: ["GET"],
middlewares: [authenticate("customer", ["session", "bearer"])],
},
{
matcher: "/store/products",
middlewares: [
@@ -0,0 +1,23 @@
import type { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework";
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
import type ProductReviewModuleService from "../../../../modules/product-review/service";
// GET /store/reviews/me -- reseñas del cliente autenticado (cualquier
// estado), para que "Mis pedidos" sepa que productos ya califico y en
// que estado de moderacion quedo cada una.
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const mine = await reviews.listProductReviews({ customer_id: req.auth_context.actor_id }, { order: { created_at: "DESC" } });
res.json({
reviews: mine.map((review) => ({
id: review.id,
product_id: review.product_id,
order_id: review.order_id,
rating: review.rating,
title: review.title,
content: review.content,
status: review.status,
created_at: review.created_at,
})),
});
}
@@ -0,0 +1,106 @@
import type { AuthenticatedMedusaRequest, MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/framework/utils";
import { PRODUCT_REVIEW_MODULE } from "../../../modules/product-review";
import type ProductReviewModuleService from "../../../modules/product-review/service";
// GET /store/reviews?product_id=... -- reseñas APROBADAS de un producto,
// mas promedio y cantidad. Publica (solo publishable key).
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const productId = typeof req.query.product_id === "string" ? req.query.product_id : "";
if (!productId) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, "product_id es obligatorio");
}
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const list = await reviews.listProductReviews(
{ product_id: productId, status: "approved" },
{ order: { created_at: "DESC" }, take: 50 },
);
const count = list.length;
const average = count ? list.reduce((sum, review) => sum + review.rating, 0) / count : 0;
res.json({
reviews: list.map((review) => ({
id: review.id,
author_name: review.author_name,
rating: review.rating,
title: review.title,
content: review.content,
created_at: review.created_at,
verified_purchase: true,
})),
average: Math.round(average * 10) / 10,
count,
});
}
type CreateReviewBody = {
product_id?: string;
order_id?: string;
rating?: number;
title?: string;
content?: string;
};
// POST /store/reviews -- solo clientes autenticados (middleware) y solo
// si el pedido indicado es SUYO, no esta cancelado y contiene el
// producto: eso es lo que respalda el sello "Compra verificada". Nace
// "pending" hasta que el dueño la apruebe en el admin.
export async function POST(req: AuthenticatedMedusaRequest<CreateReviewBody>, res: MedusaResponse) {
const customerId = req.auth_context.actor_id;
const { product_id, order_id, title } = req.body ?? {};
const rating = Number(req.body?.rating);
const content = (req.body?.content ?? "").trim();
if (!product_id || !order_id) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, "product_id y order_id son obligatorios");
}
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, "La calificación debe ser un número entero de 1 a 5");
}
if (content.length < 10 || content.length > 2000) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, "El comentario debe tener entre 10 y 2000 caracteres");
}
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const { data: orders } = await query.graph({
entity: "order",
fields: ["id", "customer_id", "status", "items.product_id"],
filters: { id: order_id },
});
const order = orders[0];
const purchased =
order &&
order.customer_id === customerId &&
order.status !== "canceled" &&
(order.items ?? []).some((item) => item?.product_id === product_id);
if (!purchased) {
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Solo puedes reseñar productos que compraste");
}
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const existing = await reviews.listProductReviews({ product_id, customer_id: customerId });
if (existing.length > 0) {
throw new MedusaError(MedusaError.Types.DUPLICATE_ERROR, "Ya dejaste una reseña para este producto");
}
const { data: customers } = await query.graph({
entity: "customer",
fields: ["first_name", "last_name"],
filters: { id: customerId },
});
const customer = customers[0];
const lastInitial = customer?.last_name ? ` ${customer.last_name.trim().charAt(0)}.` : "";
const authorName = `${customer?.first_name?.trim() || "Cliente"}${lastInitial}`;
const review = await reviews.createProductReviews({
product_id,
order_id,
customer_id: customerId,
author_name: authorName,
rating,
title: title?.trim() || null,
content,
status: "pending",
});
res.status(201).json({ review: { id: review.id, status: review.status } });
}
@@ -0,0 +1,21 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
import type ProductReviewModuleService from "../../../../modules/product-review/service";
// GET /store/reviews/summary -- promedio y cantidad de reseñas aprobadas
// por producto, para las estrellas de las tarjetas del listado (una sola
// llamada para todo el catalogo en vez de una por tarjeta).
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const approved = await reviews.listProductReviews({ status: "approved" }, { select: ["product_id", "rating"], take: 10000 });
const summary: Record<string, { average: number; count: number }> = {};
for (const review of approved) {
const entry = (summary[review.product_id] ??= { average: 0, count: 0 });
entry.average += review.rating;
entry.count += 1;
}
for (const entry of Object.values(summary)) {
entry.average = Math.round((entry.average / entry.count) * 10) / 10;
}
res.json({ summary });
}
@@ -0,0 +1,8 @@
import { Module } from "@medusajs/framework/utils"
import ProductReviewModuleService from "./service"
export const PRODUCT_REVIEW_MODULE = "productReview"
export default Module(PRODUCT_REVIEW_MODULE, {
service: ProductReviewModuleService,
})
@@ -0,0 +1,262 @@
{
"namespaces": [
"public"
],
"name": "public",
"tables": [
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": true,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"product_id": {
"name": "product_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"customer_id": {
"name": "customer_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"order_id": {
"name": "order_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"author_name": {
"name": "author_name",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"rating": {
"name": "rating",
"type": "integer",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "integer"
},
"title": {
"name": "title",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"content": {
"name": "content",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"status": {
"name": "status",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": "'pending'",
"comment": null,
"enumItems": [
"pending",
"approved",
"rejected"
],
"mappedType": "enum"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": 6,
"precision": null,
"scale": null,
"default": "now()",
"comment": null,
"enumItems": [],
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": 6,
"precision": null,
"scale": null,
"default": "now()",
"comment": null,
"enumItems": [],
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"unique": false,
"length": 6,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "datetime"
}
},
"name": "product_review",
"schema": "public",
"indexes": [
{
"keyName": "IDX_product_review_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_deleted_at\" ON \"product_review\" (\"deleted_at\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_product_review_product_id_status",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_product_id_status\" ON \"product_review\" (\"product_id\", \"status\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_product_review_customer_id",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_customer_id\" ON \"product_review\" (\"customer_id\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_product_review_product_id_customer_id_unique",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_product_review_product_id_customer_id_unique\" ON \"product_review\" (\"product_id\", \"customer_id\") WHERE deleted_at IS NULL"
},
{
"keyName": "product_review_pkey",
"columnNames": [
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {},
"nativeEnums": {}
}
],
"nativeEnums": {}
}
@@ -0,0 +1,18 @@
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
export class Migration20260927062845 extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table if exists "product_review" drop constraint if exists "product_review_product_id_customer_id_unique";`);
this.addSql(`create table if not exists "product_review" ("id" text not null, "product_id" text not null, "customer_id" text not null, "order_id" text not null, "author_name" text not null, "rating" integer not null, "title" text null, "content" text not null, "status" text check ("status" in ('pending', 'approved', 'rejected')) not null default 'pending', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_review_pkey" primary key ("id"));`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_deleted_at" ON "product_review" ("deleted_at") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_product_id_status" ON "product_review" ("product_id", "status") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_customer_id" ON "product_review" ("customer_id") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_product_review_product_id_customer_id_unique" ON "product_review" ("product_id", "customer_id") WHERE deleted_at IS NULL;`);
}
override async down(): Promise<void> {
this.addSql(`drop table if exists "product_review" cascade;`);
}
}
@@ -0,0 +1,25 @@
import { model } from "@medusajs/framework/utils"
// Reseña de producto (ADR pendiente, pedido del dueño 2026-09-27):
// solo compradores verificados -- customer_id + order_id se validan en
// POST /store/reviews contra un pedido real del cliente que contenga el
// producto -- y moderadas: nacen "pending" y solo se publican cuando el
// dueño las aprueba desde el admin (pagina "Reseñas").
export const ProductReview = model
.define("product_review", {
id: model.id({ prefix: "prev" }).primaryKey(),
product_id: model.text(),
customer_id: model.text(),
order_id: model.text(),
author_name: model.text(),
rating: model.number(),
title: model.text().nullable(),
content: model.text(),
status: model.enum(["pending", "approved", "rejected"]).default("pending"),
})
.indexes([
{ on: ["product_id", "status"] },
{ on: ["customer_id"] },
// Una reseña por cliente por producto.
{ on: ["product_id", "customer_id"], unique: true, where: "deleted_at IS NULL" },
])
@@ -0,0 +1,6 @@
import { MedusaService } from "@medusajs/framework/utils"
import { ProductReview } from "./models/product-review"
class ProductReviewModuleService extends MedusaService({ ProductReview }) {}
export default ProductReviewModuleService
@@ -0,0 +1,111 @@
# Plugins y subagentes de Claude Code (sesión "lab")
Inventario de qué plugins/skills se instalaron en la sesión principal
`lab` (no `cloud-practice`, que sigue su propia línea de práctica
multi-cloud — ver [ADR 0012](../decisiones/0012-practica-multicloud-gcp-azure.md))
y qué subagentes propios se configuraron, con la fuente real de cada
uno y por qué se aceptó o se descartó.
## Por qué esto queda documentado como el resto del lab
Dos frentes reales motivaron esto: (a) trabajo visual del storefront
ARI Shopping — ADR 0008 ya costó tres ciclos completos de
implementar-y-descartar una identidad en producción por no verla
renderizada antes de aprobarla; (b) crecimiento de habilidades de
Platform Engineering/DevOps. Un plugin instala instrucciones que
Claude sigue automáticamente, algunos con hooks que se ejecutan sin
pedir confirmación en cada uso — se aplicó la misma disciplina de
verificación que el resto del lab: quién publica (Anthropic vs
comunitario), qué permisos/hooks declara, y popularidad como señal de
confianza, antes de instalar cualquiera que no fuera 100% de Anthropic.
## Plugins instalados
| Plugin | Marketplace | Publica de verdad | Hooks | Notas |
|---|---|---|---|---|
| `frontend-design` | `claude-plugins-official` | **Anthropic** (verificado en `plugin.json`) | Ninguno | Solo skills, sin red externa |
| `context7` | `claude-plugins-official` | **Upstash** (tag `community-managed` — está *hospedado* en el catálogo oficial, no *publicado* por Anthropic) | Sin hooks, pero es un MCP que manda cada consulta al servidor remoto de Upstash (`mcp.context7.com`) | Aceptado con esa salvedad explícita de datos saliendo a un tercero |
| `superpowers` | `claude-plugins-official` | **Jesse Vincent** (`obra/superpowers`, MIT, 288k+ ⭐ en GitHub) — **NO es de Anthropic** pese a estar listado en el catálogo oficial | Sí: `SessionStart` corre un script que inyecta contexto en cada sesión nueva (revisado: solo lee su propio skill local, sin red ni nada destructivo) | Aceptado con esa salvedad — cambia comportamiento de arranque sin pedir confirmación cada vez |
| `gitops-workflows` | `devops-skills` (`ahmedasmar/devops-claude-skills`) | Ahmad Asmar (comunitario) | Ninguno (skill pura, sin ejecución automática) | |
| `k8s-troubleshooter` | `devops-skills` | Ahmad Asmar | Ninguno | |
| `ci-cd` | `devops-skills` | Ahmad Asmar | Ninguno | |
| `monitoring-observability` | `devops-skills` | Ahmad Asmar | Ninguno | |
**Descartados, no instalados:**
- **`iac-terraform`** (del mismo marketplace `devops-skills`) — descartado a propósito, no por falta de calidad. Terraform ya se maneja aparte vía `hashicorp/agent-skills` en la sesión `cloud-practice` (ver ADR 0012 Fase 2); instalarlo acá también duplicaría/confundiría la fuente de verdad.
- **`aws-cost-optimization`** (mismo marketplace) — no se pidió, se dejó afuera para no sumar alcance no solicitado.
- **`theme-factory`** — investigado y descartado: el mismo nombre aparece en al menos 4 fuentes comunitarias distintas y no relacionadas entre sí (`kivilaid`, `shipshitdev`, `composiohq`, etc.), ninguna en el catálogo oficial de Anthropic. Sin una fuente única verificable, no se instaló nada con ese nombre.
- **`ui-theme-designer`** (alternativa considerada del catálogo oficial) — es de **SAP SE**, específico para el SAP Fiori Design System / UI5 Web Components. No tiene nada que ver con el stack real de ARI Shopping (Next.js + Tailwind) — se descartó por no aplicar, no por falta de confianza.
## Subagentes propios (`~/.claude/agents/`)
Tres subagentes con propósito acotado y tools restringidas, definidos
como archivos markdown con frontmatter YAML (formato estándar de
Claude Code — ver [documentación oficial](https://code.claude.com/docs/en/sub-agents)).
### `devsecops-auditor`
Solo lectura y explicación del pipeline DevSecOps real (Gitleaks,
Trivy, Semgrep, Syft, Cosign) en `apps-registry`/`platform-infra`/
`scripts`. `tools: Read, Grep, Glob, Bash` + `disallowedTools: Write,
Edit` — nunca modifica código de producción, su único output es
diagnóstico contra el YAML/config real, citando archivo:línea, nunca
teoría genérica de CI/CD.
### `frontend-critic`
Revisa visualmente un cambio del storefront **antes** de aprobarlo —
formaliza la disciplina "simular antes de implementar" que costó tres
ciclos de descarte en ADR 0008. Regla dura: si no puede ver el
resultado renderizado en esa corrida puntual, el veredicto es "no
puedo aprobar esto sin verlo", nunca un aprobado condicional.
Vía de verificación, en orden de preferencia:
1. Chrome DevTools / Claude in Chrome — **hoy no disponible en esta
sesión** (corre headless dentro de `tmux-host`, sin navegador real
conectado — mismo gotcha ya documentado para Claude in Chrome en
general).
2. Playwright vía Docker en la NAS (el workaround ya validado este
mismo lab, ver `.browser-scratch/README.md`) contra
`shop.cruzcloud.net`/`staging.cruzcloud.net` reales.
Usa la skill `frontend-design` (oficial, ver tabla de arriba) como
marco de criterio de calidad visual.
### `cloud-cost-auditor`
Definido de antemano, a propósito, **sin nada que auditar todavía**.
Necesita los MCP de GCP/Azure que se configuran en la sesión
`cloud-practice` (ADR 0012) para funcionar — no va a tener recursos
reales que revisar hasta que esa línea de trabajo pase de la Fase 1.
Su propio system prompt tiene la regla dura de decir explícitamente
"no tengo nada que auditar todavía" en vez de inventar un análisis si
lo invocan antes de tiempo. No es un bloqueante para nada de lo demás,
solo queda listo para cuando aplique.
## Alcance y persistencia de la instalación
Los 7 plugins quedaron en **`scope: user`** (confirmado con
`claude plugin list`), no a nivel de proyecto de un repo específico.
Los 3 subagentes viven en `~/.claude/agents/` (también scope de
usuario, ubicación estándar según la doc oficial).
**Verificado en vivo que sobrevive a un reinicio del contenedor**,
mismo criterio que el resto de la configuración persistente de
`tmux-host` (ver [Sesión tmux persistente](sesion-tmux-persistente.md)):
`stat -c '%d' /root/.claude` y `stat -c '%d'
/DATA/AppData/k3d-lab-v3/claude-code-home` devuelven el mismo device
ID — es el mismo bind mount ya validado para login/confianza de
carpeta, no un directorio efímero del contenedor.
**Gotcha encontrado en vivo:** tanto los plugins como los subagentes
nuevos **no quedaron activos en la misma sesión donde se instalaron/
crearon** — la doc oficial de subagentes dice que se cargan sin
reinicio, *excepto* la primera vez que se crea el directorio
`~/.claude/agents/` (que no existía antes en este lab). Confirmado en
vivo: invocar `devsecops-auditor` en la misma sesión dio
`Agent type 'devsecops-auditor' not found`. Ambos (plugins y
subagentes) quedan pendientes de confirmar activos recién en el
próximo arranque real de la sesión — no es una falla de instalación,
es el comportamiento documentado.
@@ -408,6 +408,14 @@ después del swap.
| `docker restart tmux-host` (reinicio del NAS) | **No al toque, pero se auto-recupera solo** — el proceso `tmux server` muere con el contenedor; `healthcheck.sh` la recrea en el próximo ciclo (máx. 30 min) sin intervención, o `lab-session.sh` la crea al instante si alguien se reconecta antes | Sí, vía el volumen montado en `/root/.claude` | Sí, desde el 2026-09-20 — `--continue` retoma la conversación más reciente en `WORKDIR` (ver gotcha arriba) |
| `docker rm` + recrear el contenedor desde cero | No, pero igual se auto-recupera sola (mismo mecanismo de arriba) | Sí, vía el mismo volumen — no hace falta volver a autenticar ni reconfirmar el diálogo de confianza del folder (ver el mount de `claude-code-home.json`) | Sí, mismo mecanismo — validado en vivo durante el swap de imagen del 2026-09-20 |
| Falla de red real del host/NAS | N/A | N/A — `healthcheck.sh` la detecta y notifica, pero no la corrige (fuera de su alcance) | N/A |
| Expiración del token OAuth de Claude Code | Sí — el proceso `claude` sigue vivo en el pane, pero muestra `Login expired` y Remote Control queda desconectado (`OAuth token unavailable`) | **No** — requiere `/login` manual (una vez alcanza: el volumen de credenciales es compartido) y después `/remote-control` en **cada** sesión ya abierta. Recrear el contenedor **no** lo arregla | Sí, una vez reautenticado — misma sesión, mismo proceso |
!!! warning "Incidente relacionado"
La fila de expiración del token OAuth salió del
[incidente de la cadena de 4 fallos en `tmux-host` (2026-09-25/26)](../playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md).
El síntoma "no aparecen las sesiones remotas" llevó a eliminar el
contenedor, aunque la causa real era un token vencido. **Antes de
tocar el contenedor, mirá el pane de cada sesión.**
## Validación en vivo
@@ -425,10 +425,10 @@ Resultados reales, uno por uno (código de cada prueba en
un dispositivo real.
21b. **Accesibilidad del checkout completo** por teclado (no solo el
buscador) — tab order y foco visible en los 3 pasos.
22. **Importación manual de imágenes no se reflejaba en el storefront**
(reportado por el usuario 2026-09-20) — **causa raíz encontrada y
arreglada en código (2026-09-20), pendiente confirmar con una
edición real.** `medusaFetch()` en `lib/headless/providers/medusa.ts`
22. **Importación manual de imágenes no se reflejaba en el storefront —
RESUELTO 2026-09-20, con un giro real durante la verificación.**
Reportado por el usuario 2026-09-20. Causa raíz #1 (caché
multi-pod): `medusaFetch()` en `lib/headless/providers/medusa.ts`
usaba `next:{revalidate:60,tags:["medusa-products"]}`, caché en
memoria por-pod que sobrevivía a `force-dynamic`. Con
`frontend-deploy` en 2 réplicas sin cacheHandler compartido, el POST
@@ -437,11 +437,28 @@ Resultados reales, uno por uno (código de cada prueba en
TTL expirara solo. Una edición puntual + revisar al toque tenía
~50% de chance de pegarle al pod stale; una importación CSV (mucho
más lenta) le daba tiempo de sobra al TTL para expirar en ambos.
**Fix:** se sacó el `next.revalidate`/`tags` del fetch — ya en
producción. Falta la prueba con una edición manual real (vía
`temp-admin`/`ari-shopping-tienda` en memoria) para confirmar que
el síntoma original desapareció, no solo que la causa técnica tiene
sentido.
**Fix #1:** se sacó el `next.revalidate`/`tags` del fetch.
Verificado en vivo por el usuario con una edición real (agregó
varias fotos manuales al producto `minnie-y-mickey-mouse`): las
fotos sí se reflejaron al toque, confirmando el fix #1 — pero
apareció un segundo síntoma real, no relacionado: "el zoom
interactivo no se visualiza" en las fotos subidas a mano, aunque sí
en las de CSV. **Causa raíz #2** (`components/product/product-gallery.tsx`):
el panel/overlay de zoom arma `backgroundImage: \`url(${selected})\``
sin comillas — un nombre de archivo con espacio y paréntesis (ej.
`descarga (4)-xxx.png`, el patrón por defecto de Chrome al descargar
una imagen sin renombrarla, exactamente como quedan las subidas
manuales, a diferencia de las de CSV que llegan con nombres
saneados) rompe la sintaxis de `url()` sin comillas — el navegador
descarta el valor completo en silencio (`backgroundImage: none`).
La lupa (recuadro) sí aparecía porque no depende de la URL; solo el
panel/overlay quedaba en blanco. **Fix #2:** comillas alrededor de
la URL en las dos ocurrencias del patrón. Verificado en vivo contra
las 9 fotos reales del producto (2 de CSV + 7 manuales con
espacio+paréntesis) con Playwright: `computed backgroundImage`
pasó de `none` a `url("...")` correcto en las 7 que fallaban, sin
tocar las 2 que ya andaban bien.
23. **En mobile (viewport real de 320px), la página se cortaba y
obligaba a scrollear horizontalmente — RESUELTO 2026-09-20.**
Reportado por el usuario con captura real de `shop.cruzcloud.net`.
@@ -0,0 +1,321 @@
# ADR 0012 — Práctica multi-cloud: GCP y Azure DevOps en paralelo al lab ZimaOS
**Estado:** aceptada — Fase 1 (Google Workspace) completa y verificada
en vivo, con un giro real: el MCP self-hosted planeado
(`taylorwilsdon/google_workspace_mcp`) quedó abandonado por un límite
real de esa librería (ver §Fase 1), reemplazado por los Connectors
nativos de claude.ai. Fases 2-4 quedan documentadas como roadmap,
explícitamente no implementadas hasta que se pidan en una sesión
futura.
**Fecha:** 2026-09-20 (Fase 1 cerrada 2026-09-21)
## Contexto
El lab GitOps de CruzCloud (ZimaOS + k3d + Gitea + Argo CD) ya prueba en
vivo, con rigor de ADR, patrones que después se pueden proponer en
trabajo real con clientes — pero corre íntegro sobre hardware propio,
sin tocar ningún proveedor de nube público. Dos motivaciones concretas
empujan a abrir una segunda línea de práctica en paralelo:
1. **Practicar herramientas que el cliente actual restringe.** Ciertas
plataformas/herramientas de nube no están disponibles en el entorno
de trabajo del cliente actual — la única forma real de ganar
experiencia con ellas es un entorno propio.
2. **Preparar propuestas de arquitectura con evidencia, no solo teoría.**
Poder decir "ya lo corrí, así se ve, esto cuesta X" en una propuesta
a un cliente vale más que una descripción de manual.
3. **Portafolio.** Los mismos ADRs, diagramas y decisiones documentadas
que ya sostiene este lab sirven como muestra de trabajo verificable.
**Alcance confirmado explícitamente por el usuario, no asumido:**
- **Entorno paralelo, no migración.** ZimaOS sigue siendo el lab
principal — nada de lo de acá reemplaza nada de lo ya construido.
- **Cuentas personales únicamente.** Nunca credenciales del cliente
actual ni de ningún cliente futuro — separación total.
- **Costo cero o mínimo**, con alertas de presupuesto como red de
seguridad desde el primer recurso pago que se cree (ver
[Análisis de costo](#analisis-de-costo-explicito) más abajo).
- Mismo rigor documental que el resto del lab: un ADR por decisión
arquitectónica real, diagramas Mermaid, cifras verificadas en vivo o
contra fuente primaria (no contra un blog de terceros sin
corroborar — ver el caso del supuesto MCP "oficial" de Google
Workspace en la [Fase 1](#fase-1-ejecutada-google-workspace-con-un-giro-real-en-la-implementacion)).
## Diagrama del lab actual (ZimaOS)
Versión simplificada, con solo los componentes relevantes para la
comparación de este ADR — el diagrama completo (con Traefik,
`serverlb`, cada `Host` de Ingress, etc.) vive en
[Visión general](../arquitectura/vision-general.md).
```mermaid
flowchart LR
User(["Usuario"]) -->|HTTPS| CF["Cloudflare Tunnel<br/>(sin puertos abiertos)"]
CF --> NPM["Nginx Proxy Manager<br/>enrutamiento por dominio"]
NPM --> K3D["k3d<br/>(cluster K8s local, Docker)"]
subgraph K3D_INT["Dentro de k3d"]
ARGO["Argo CD"] -->|sync| APPS["Apps reales<br/>(ecommerce, docs-portal)"]
GIT["Gitea<br/>(auto-alojado)"] -.->|watch repos| ARGO
end
K3D --> K3D_INT
```
**Costo real:** $0 — hardware propio (NAS ZimaOS), sin proveedor de
nube de por medio.
## Diagrama Fase 1 — entorno paralelo en GCP (VM + k3s)
**Esto NO es GKE administrado.** Es una VM `e2-micro` corriendo k3s
self-managed, elegida específicamente para mantener el costo real en
$0 — GKE (administrado, ver [Fase 4](#fase-4-documentar-no-ejecutar-gke-real-con-spot-auto-apagado))
no tiene una vía 100% gratuita permanente (ver el análisis de costo
más abajo).
```mermaid
flowchart LR
User(["Usuario / Claude"]) -->|SSH / kubectl| VM["Compute Engine e2-micro<br/>Always Free — us-west1/us-central1/us-east1<br/>NO es GKE administrado"]
subgraph VM_INT["Dentro de la VM"]
K3S["k3s<br/>(single-node, self-managed)"]
ARGO2["Argo CD<br/>(instancia propia, no la de ZimaOS)"] -->|sync| APPS2["Manifiestos del nuevo repo<br/>(mismo patrón base+overlay<br/>de platform-templates)"]
end
VM --> VM_INT
GIT_EXISTENTE["Gitea existente<br/>gitea.cruzcloud.net<br/>(reusado, repo nuevo, NO uno nuevo)"] -.->|watch| ARGO2
```
**Por qué reusar el Gitea existente en vez de levantar uno nuevo en
GCP:** el objetivo es practicar el patrón GitOps completo (Kustomize
base+overlay + Argo CD sincronizando) con el mínimo de piezas nuevas —
levantar un segundo Gitea solo sumaría superficie a mantener sin
enseñar nada nuevo sobre GCP en sí. El *cómputo* (la VM, k3s) es lo que
cambia de proveedor; el origen de verdad (Git) se queda donde ya
funciona.
**Costo real:** $0 — `e2-micro` es Always Free **de forma permanente**
(no es el trial de $300/90 días), siempre que la VM viva en
`us-west1`, `us-central1` o `us-east1` y no supere 1 instancia
no-preemptible + 30 GB-mes de disco persistente estándar + 1 GB/mes de
salida hacia Norteamérica — límites reales del programa, verificados
contra la documentación oficial de Google Cloud.[^1]
## Fase futura (no implementada) — GKE real con Spot + auto-apagado
Marcada explícitamente como **fase futura, no implementada** — ver
[Fase 4](#fase-4-documentar-no-ejecutar-gke-real-con-spot-auto-apagado)
para cuándo se decidiría ejecutarla.
```mermaid
flowchart LR
TF["Terraform<br/>(provisiona todo)"] --> GKE["GKE Standard<br/>1 cluster zonal"]
GKE --> NP["Node pool<br/>Spot VMs (e2-small)"]
SCHED["Cloud Scheduler<br/>(cron: apagar/prender)"] -->|invoca| FN["Cloud Run/Functions<br/>job de auto-apagado"]
FN -->|resize node pool<br/>a 0 / de vuelta| NP
style GKE fill:#333,stroke:#f90,color:#fff
style NP fill:#333,stroke:#f90,color:#fff
style SCHED fill:#333,stroke:#f90,color:#fff
style FN fill:#333,stroke:#f90,color:#fff
```
### Mecanismo de auto-apagado: comparación antes de recomendar
Dos mecanismos reales existen para esto; se investigaron ambos antes de
elegir uno, sin asumir cuál es "obviamente" mejor:
| | Escalar node pool a 0 | Destruir/recrear cluster completo (`terraform apply`/`destroy` programado) |
|---|---|---|
| Qué apaga | Solo el cómputo (los nodos) | Todo, incluido el control plane |
| Ahorra el fee de control plane ($0.10/hora) | No — pero **ya está cubierto por el crédito mensual de $74.40** para un único cluster, así que no hay nada real que ahorrar ahí | Sí, pero es un ahorro sobre algo que ya es $0 |
| Tiempo de "volver a prender" | ~1-3 min (crear nodos sobre un cluster que ya existe) | ~5-10 min (crear el cluster entero de cero) |
| Piezas para automatizarlo | Cloud Scheduler → Cloud Function/Run → 1 llamada a la API de GKE (resize node pool) | Cloud Scheduler → runner con Terraform + credenciales + state remoto seguro → `apply`/`destroy` completo |
| Riesgo de drift/config perdida | Bajo — el cluster nunca se borra | Mayor — cualquier cosa hecha fuera de Terraform se pierde en cada destroy |
| Patrón documentado en la industria | Sí, es el patrón estándar (Cloud Scheduler + Pub/Sub + Cloud Function), con casos reales de ~30% de ahorro de cómputo en clusters no-críticos[^2] | Existe, pero es la opción menos común para este caso puntual |
**Recomendación (sin implementar todavía):** escalar el node pool a
0/de vuelta vía Cloud Scheduler + Cloud Function, no destruir/recrear
el cluster completo. Razón concreta: el crédito de $74.40/mes **ya
cubre el fee de control plane de un único cluster exista o no exista
un nodo adentro** — destruir el cluster entero no ahorra nada sobre
ese componente porque ya es $0, y a cambio suma la complejidad real de
manejar Terraform state de forma segura dentro de un cron job
automatizado, más un tiempo de arranque 3-5x más largo. La única razón
real para preferir destruir/recrear sería eliminar *toda* huella en
GCP entre usos (por ejemplo, para no depender de que el crédito
mensual siga existiendo) — no es el caso acá.
## Tabla comparativa
| | Lab actual (ZimaOS) | GCP Fase 1 (VM + k3s) | GCP Fase futura (GKE + Spot) |
|---|---|---|---|
| Cómputo | NAS propio, Docker/k3d | Compute Engine `e2-micro` | GKE Standard, node pool Spot `e2-small` |
| ¿Kubernetes administrado? | No (k3d local) | No (k3s self-managed) | Sí (GKE Standard) |
| Control plane | N/A (local) | N/A (k3s corre en la misma VM) | $0.10/hora — cubierto por crédito de $74.40/mes para 1 cluster |
| Cómputo de nodos | $0 (hardware propio) | $0 (Always Free permanente) | ~$7-10/mes (Spot `e2-small`, sin cubrir por ningún crédito) |
| GitOps | Argo CD + Gitea, ambos autoalojados | Argo CD propio en la VM, mismo Gitea reusado | Igual que Fase 1, sobre GKE |
| Costo mensual real | $0 | $0 | ~$7-10/mes (no $0) |
| Estado | En producción, uso diario | A ejecutar esta sesión (Parte 2/3) | Documentado, no implementado |
## Análisis de costo explícito
**No existe un GKE administrado 100% gratis de forma permanente.** El
crédito de $74.40/mes por cuenta de facturación cubre exactamente el
fee de control plane ($0.10/hora) de **un** cluster zonal Standard o
Autopilot — nunca el cómputo real. GKE Autopilot factura desde el
primer pod que se programa (vCPU/memoria/almacenamiento por pod), sin
ningún equivalente al Always Free de Compute Engine; GKE Standard sí
puede correr sobre un único nodo Spot/preemptible barato (~$7/mes para
un `e2-small`, verificado contra tarifas actuales), pero ese cómputo
nunca es gratis.[^3]
**La única ruta de costo cero real y permanente es Compute Engine
`e2-micro` (Always Free) + k3s self-managed** — exactamente la Fase 1
de este ADR. No es el trial de $300/90 días (que expira y no es
"gratis", es prepago con reloj corriendo); es un beneficio permanente
del programa de nivel gratuito de Google Cloud, activo mientras la VM
cumpla los límites de región/tamaño/tráfico ya descritos arriba.
**Activar cualquier billing account de GCP requiere tarjeta igual**,
incluso para quedarse exclusivamente en recursos Always Free — Google
verifica el medio de pago aunque el cargo real sea $0. Detalle no
obvio, verificado contra documentación oficial: **las alertas de
presupuesto no se pueden configurar mientras la cuenta esté operando
sobre crédito gratuito de prueba** — hace falta pasar la cuenta de
facturación a estado "paga" (el uso real sigue siendo $0 si no se sale
del Always Free) para que la opción de crear un presupuesto/alerta
quede disponible.[^4] **Recomendación:** hacer ese paso — pasar a
cuenta de facturación paga — como parte del setup inicial, y configurar
de inmediato una alerta de presupuesto en **$1-5 USD** como red de
seguridad, antes de crear ningún recurso más allá de lo cubierto por
Always Free.
## Secuencia de fases
Todas documentadas acá; **solo la Fase 1 se ejecuta en esta sesión.**
Las Fases 2-4 no se implementan sin pedido explícito en una sesión
futura — quedan como roadmap, no como trabajo en curso.
### Fase 1 (ejecutada) — Google Workspace, con un giro real en la implementación
**Objetivo cumplido — acceso real a Google Calendar desde Claude,
verificado en vivo — pero por un camino distinto al planeado
originalmente.** Detalle de la investigación previa (qué MCP usar,
oficial vs comunitario) — resumen: **no existe un MCP oficial de
Google Workspace** publicado por Google (se verificó explícitamente
buscando en los GitHub `google`/`googleworkspace` oficiales — no
aparece ninguno; un solo blog de terceros afirma que "ya existe uno
oficial" sin que ninguna otra fuente lo corrobore, así que se descarta
esa afirmación por no verificable). Se optó por
`taylorwilsdon/google_workspace_mcp` (comunitario, el más completo y
activamente mantenido: 120+ herramientas, OAuth 2.1, +2500 estrellas)
como primer intento, self-hosted dentro de `tmux-host`.
**Ese self-hosted quedó abandonado — límite real encontrado en la
librería, no un error de configuración.** El servidor usa FastMCP's
`GoogleProvider`/`OAuthProxy`, que construye el `redirect_uri`
enviado a Google directamente desde su propio `base_url` — **no**
desde la variable `GOOGLE_OAUTH_REDIRECT_URI` que el proyecto expone
(esa variable solo valida entradas, no arma la salida real; hallazgo
confirmado leyendo el código fuente instalado, no la documentación
pública). Y `base_url` es la misma variable de la que depende por
default `issuer_url`, que el SDK de MCP exige sea HTTPS salvo que sea
literalmente `localhost`/`127.0.0.1` (chequeo hardcodeado en
`mcp/server/auth/routes.py:validate_issuer_url`, sin variable de
entorno que lo salte). Resultado: **no hay combinación de variables
de entorno que satisfaga a la vez** "el navegador del usuario debe
poder llegar al callback" (necesita un dominio real, no `localhost`)
y "el issuer debe ser HTTPS o `localhost`" — `workspace-mcp` no
expone `issuer_url` por separado de `base_url` en su capa de
configuración (confirmado en `core/server.py`: solo pasa `base_url` a
`GoogleProvider(...)`).
**Decisión del usuario ante ese límite:** en vez de armar un proxy
HTTPS con certificado autofirmado (infraestructura extra, fricción de
certificado no confiable en cada uso) o parchear el paquete instalado
a mano (frágil, se pierde en cualquier reinstalación), se usaron los
**Connectors nativos de claude.ai** (Google Calendar, vía el
directorio de conectores de la cuenta, fuera de Claude Code por
completo) — mismo resultado práctico (Claude con acceso real al
calendario personal), infraestructura cero de nuestro lado, sin
tocar código de terceros. Contrapartida real: es una integración de
cuenta de claude.ai, no un MCP configurado dentro del proyecto
`platform-multicloud` — no aparece como herramienta en sesiones de
Claude Code (`lab`/`cloud-practice`), solo en el chat normal de
claude.ai.
**Verificado en vivo:** pregunta real en claude.ai normal ("¿qué
tengo en el calendario esta semana?") devolvió una respuesta correcta
revisando 3 calendarios reales de la cuenta (principal, Familia,
festivos de Colombia) vía la herramienta `Google Calendar: Lists
calendar events`, con razonamiento correcto sobre qué cuenta como
"esta semana" dado el día/hora real de la consulta.
### Fase 2 (documentar, no ejecutar) — Terraform aislado
Instalar el marketplace oficial de HashiCorp Agent Skills, y practicar
aprovisionando algo mínimo y desechable en GCP. Nota para cuando se
ejecute: HashiCorp también publica un **MCP Server de Terraform**
propio y oficial (`hashicorp/terraform-mcp-server`, integra con el
Terraform Registry y HCP Terraform) — es una pieza distinta a los
Agent Skills (el MCP es el "tubo" hacia herramientas reales; los
Skills son paquetes de conocimiento/instrucciones) y vale la pena
evaluar si se usan juntos o solo uno de los dos en ese momento.
### Fase 3 (documentar, no ejecutar) — Azure DevOps en paralelo
MCP oficial `microsoft/azure-devops-mcp` (confirmado como el oficial y
activamente mantenido por Microsoft, hay varios forks/alternativas
comunitarias que no son la vía recomendada). Replicar el mismo
pipeline DevSecOps que ya corre en Gitea Actions para este lab
(Gitleaks → Trivy → Semgrep → Syft → Cosign) como Azure Pipelines YAML,
en un proyecto personal — mismo objetivo de práctica que la Fase 1:
tener el patrón ya probado para proponerlo en trabajo con clientes que
usen Azure DevOps.
### Fase 4 (documentar, no ejecutar) — GKE real con Spot + auto-apagado
Solo si se decide conscientemente asumir el costo bajo pero no-cero
(~$7-10/mes, ver el análisis de costo arriba) — vía Terraform, node
pool Spot, y el mecanismo de auto-apagado recomendado en la sección de
arriba (escalar a 0, no destruir/recrear el cluster).
## Opciones consideradas
| Opción | A favor | En contra |
|---|---|---|
| Entorno paralelo en GCP+Azure (elegido) | Practica exactamente lo que el cliente actual restringe; portafolio con evidencia real; ZimaOS sigue intacto | Dos entornos más que mantener; curva de aprendizaje de dos proveedores nuevos a la vez |
| Migrar el lab completo a un solo cloud | Un solo entorno que mantener | Pierde justo lo que se quiere conservar (costo $0, control total de hardware); no es lo que se pidió |
| Solo AWS (el "default" de la industria) | Ecosistema más grande | El cliente actual ya usa/restringe otras plataformas específicas — practicar AWS no cierra esa brecha puntual |
| GKE administrado desde el día 1 (saltear la VM self-managed) | Más parecido a "producción real" desde el inicio | Rompe el requisito de costo cero explícito del usuario — ningún tier de GKE es gratis de forma permanente |
## Decisión
Se abre una línea de práctica multi-cloud paralela al lab ZimaOS, en
cuentas personales, arrancando por Compute Engine `e2-micro` (Always
Free) + k3s self-managed en GCP como Fase 1 de cómputo, y Google
Workspace vía MCP comunitario (`taylorwilsdon/google_workspace_mcp`)
como primer MCP a configurar en esta sesión. Terraform, Azure DevOps y
GKE administrado quedan documentados como Fases 2-4, sin implementar.
## Consecuencias
- **Se gana** un segundo entorno real, documentado con el mismo rigor
de ADR que ya sostiene el lab, listo para mostrar en propuestas a
clientes — sin tocar nunca credenciales de cliente.
- **Se sacrifica/queda como deuda técnica:** la Fase 1 de GCP usa k3s
self-managed, no un Kubernetes administrado real — cualquier lección
específica de operar GKE (autoscaling nativo, node pools, IAM de
GCP) queda pendiente hasta la Fase 4, y esa fase sí tiene costo real
(~$7-10/mes) que hoy no se está pagando.
- **Para revertir:** dar de baja la VM `e2-micro` y (si se llegó a
crear) el node pool de la Fase 4 — ningún componente de este ADR
toca o depende del lab ZimaOS existente, así que no hay nada que
revertir de ese lado.
[^1]: [Compute Engine Free Tier — Google Cloud](https://cloud.google.com/free/docs/compute-getting-started), [GCP Free Tier Complete Guide 2026](https://agentdeals.dev/gcp-free-tier-2026)
[^2]: [Reduce Your GKE Bill with Cloud Scheduler by Scaling to Zero](https://chimbu.medium.com/reduce-your-gke-bill-with-cloud-scheduler-by-scaling-to-zero-during-off-peak-hours-9796bb4f7b8b), [Scaling GKE Standard Clusters to Zero](https://medium.com/@gustavo.romerobenitez_34616/scaling-gke-standard-clusters-to-zero-56bf5270510e)
[^3]: [GKE Pricing Guide 2026 — Sedai](https://sedai.io/blog/google-cloud-kubernetes-cluster-cost), [e2-small pricing — Economize](https://www.economize.cloud/resources/gcp/pricing/compute-engine/e2-small/), [Google Kubernetes Engine pricing — Google Cloud](https://cloud.google.com/kubernetes-engine/pricing)
[^4]: [Google Cloud Free Trial FAQs](https://cloud.google.com/signup-faqs), [Create, edit, or delete budgets and budget alerts — Google Cloud](https://docs.cloud.google.com/billing/docs/how-to/budgets)
@@ -0,0 +1,122 @@
# ADR 0013 — Iniciativas de clientes: Google Apps Script y AppSheet
**Estado:** aceptada — **fase prioritaria**, precede la reanudación de
las Fases 2-4 del [ADR 0012](0012-practica-multicloud-gcp-azure.md).
Scaffold del repo en curso; primeras iniciativas reales se agregan
sesión a sesión, a medida que se comparten.
**Fecha:** 2026-09-21
## Contexto
Iniciativas propias de la empresa (no del lab), trabajadas con **cuenta
personal de Google** — dos herramientas concretas: **Google Apps
Script** (código, automatizaciones) y **Google AppSheet** (apps
no-code sobre Sheets). Se abre como fase separada del ADR 0012 y con
prioridad sobre él por decisión explícita: retomar las Fases 2-4 del
lab (Terraform, Azure DevOps, GKE) espera a que esto avance primero.
**Restricción dura, no negociable:** ningún repo ni documento de esta
iniciativa contiene credenciales, datos ni información real de
cliente. Apps Script versiona código (lógica), nunca datos. AppSheet
no tiene export de código — solo se documenta desde afuera (tablas,
columnas, expresiones, capturas), nunca se copian datos reales de
ninguna hoja de cálculo al repo.
## Opciones consideradas
### Apps Script: MCP vs. `clasp`
| Opción | A favor | En contra |
|---|---|---|
| MCP para Apps Script | Interacción conversacional directa | **No existe un MCP oficial de Google.** Se verificó explícitamente buscando — solo hay servidores comunitarios (`mohalmah/google-appscript-mcp-server`, `whichguy/gas_mcp`, entre otros), ninguno publicado por Google. Se descartan sin evaluar más a fondo: no hay necesidad de agregar una dependencia comunitaria no verificada cuando existe una CLI oficial que cubre el caso de uso completo. |
| **`clasp`** (elegido) | CLI oficial de Google (`@google/clasp`), mantenida por el equipo de Apps Script, cubre push/pull/login/versiones/despliegues | Requiere login interactivo (OAuth) — mitigado: el login lo corre el dueño de la cuenta, nunca se comparte el código de autorización |
### AppSheet: qué se puede versionar
Se verificó contra la documentación oficial vigente (no se asumió):
**AppSheet no expone ningún mecanismo de export de la definición de la
app** (tablas virtuales, vistas, expresiones, automatizaciones) hacia
un formato versionable con git. La única superficie exportable es la
app en sí vía la consola de AppSheet, no un artefacto de código. Existe
un addon de terceros ("App Script Saver") pero es específico de Apps
Script, no de AppSheet, y no se evaluó como parte de esta decisión
(mismo criterio que con los MCP: no se instala nada de terceros sin
pasar primero por la verificación de publicador/permisos ya establecida
en el lab).
**Decisión sobre AppSheet:** no se intenta versionar la app —se
documenta desde afuera. La documentación (markdown + capturas) es la
única fuente versionada; la app real en la consola de AppSheet sigue
siendo la fuente de verdad operativa, con el riesgo explícito de que
la documentación quede desactualizada (ver Consecuencias).
## Decisión
Repo privado nuevo `client-initiatives` en Gitea (separado de
`apps-registry`/`platform-infra`/`scripts`/`platform-multicloud` — ni
lab GitOps ni práctica multi-cloud, es su propia categoría). Estructura:
una carpeta por iniciativa bajo `apps-script/` (proyecto `clasp`
completo, con su propio `.clasp.json`) o `appsheet/` (documentación +
plantilla de estructura vacía). Mismo criterio de versionado semántico
y modularidad que ya usa `docs-portal`.
**Flujo Apps Script:** rama `fix/*` → diff mostrado → `merge --no-ff`
a `main`. `clasp push` (subir el código a Google) es una confirmación
explícita **aparte**, nunca agrupada con el merge a git — son dos
sistemas de versionado distintos (git para el repo, Apps Script para
las versiones desplegadas) y no deben confundirse.
**Gitleaks:** se agrega al pipeline del repo nuevo. Nota real
encontrada al planificar esto: Gitleaks hoy solo corre en
`apps-registry` (3 workflows) — `platform-infra` y `scripts` no tienen
Gitea Actions configurado en absoluto, no hay "los otros tres repos con
Gitleaks" como se asumió al principio. Se replica el step exacto que
ya usa `apps-registry` (Gitleaks v8.21.2, `--no-git --redact`, reporte
JSON) por ser el único precedente real en el lab.
## Fases
### Fase A — Google Apps Script
1. Scaffold del repo `client-initiatives` (estructura, Gitleaks,
convenciones documentadas).
2. Login de `clasp` (`clasp login --no-localhost`) corrido por el
dueño de la cuenta — verificación posterior de que
`~/.clasprc.json` queda fuera de cualquier repo y con `chmod 600`.
3. Primeras iniciativas: pendientes de compartir, se agregan una por
una bajo `apps-script/<slug>/` a medida que llegan.
### Fase B — Google AppSheet
1. Por cada app existente: documentar tablas, columnas y expresiones
en markdown, más capturas del estado actual, bajo
`appsheet/<slug>/`.
2. Plantilla de estructura de datos vacía si aplica (schema, nunca
filas reales).
3. Limitación dejada explícita en cada README de iniciativa: sin diff
automático posible entre esta doc y la app real.
## En pausa mientras esta fase esté activa
Por decisión explícita del usuario, prioridad sobre lo ya documentado
en el ADR 0012 y el resto del lab:
- **Swap de `tmux-host`** — no se corre en esta fase.
- **[Fase 4 del ADR 0012](0012-practica-multicloud-gcp-azure.md#fase-4-documentar-no-ejecutar-gke-real-con-spot-auto-apagado)**
(GKE real con Spot + auto-apagado) — sigue solo documentada.
- **Prueba del skill `frontend-design`** (pendiente desde la
instalación de plugins del 2026-09-20) — no se ejecuta.
Tampoco se toca NPM, `cloudflared`, ni `lab-agent` durante esta fase.
## Consecuencias
- Se gana un espacio versionado y auditado (Gitleaks) para
automatizaciones reales de la empresa, sin mezclar nada con el lab
GitOps ni con la práctica multi-cloud.
- Se acepta como deuda consciente que la documentación de AppSheet
puede desincronizarse de la app real — no hay forma técnica de
evitarlo, solo disciplina de actualizarla.
- Revertir esta decisión implicaría: eliminar el repo
`client-initiatives` (las iniciativas seguirían existiendo en Google,
solo se perdería el historial versionado y la documentación externa).
@@ -0,0 +1,385 @@
# Incidente: cadena de 4 fallos en `tmux-host` (2026-09-25/26)
| | |
|---|---|
| **Ventana del incidente** | Noche del 2026-09-25 al 26 (hora local); en `health.log`, de 2026-09-26 04:11Z a ~06:20Z |
| **Servicio afectado** | Contenedor `tmux-host`: las sesiones `lab` y `cloud-practice` de Claude Code y su Remote Control desde claude.ai (ver [Sesión tmux persistente](../arquitectura/sesion-tmux-persistente.md)) |
| **Impacto** | Las sesiones remotas no aparecían en claude.ai. Mientras se intentaba arreglar, se perdió el login y la confianza del folder, el supervisor del healthcheck quedó muerto y `claude` no podía conectarse a Anthropic |
| **Disparador** | Se eliminó el contenedor `tmux-host` a mano creyendo que era la causa |
## Contexto / disparador
El síntoma inicial era uno solo: **"no toma las sesiones remotas"**. Las
sesiones de Claude Code dentro de `tmux-host` no aparecían en claude.ai.
Parecía un problema del contenedor, así que se eliminó `tmux-host` para
recrearlo desde cero.
En realidad ese síntoma tenía una causa que no tenía nada que ver con el
contenedor: el token OAuth estaba vencido (causa **d**, más abajo). Al
recrear el contenedor, la mitad de la noche se fue en resolver tres fallos
reales que *aparecieron* en el camino (causas **a**, **b** y **c**). Cada
uno se encontró recién después de resolver el anterior. Recién al final
apareció la causa original.
!!! info "Fuentes de la evidencia"
Este playbook se escribió desde dentro del `tmux-host` ya recuperado.
Los datos marcados **(verificado)** se confirmaron contra el estado
real del sistema al escribirlo: `docker inspect`, `docker images`,
`health.log`, `sysctl`, `curl`, `git diff` y los panes de tmux. Los
marcados **(reportado)** vienen del relato de la sesión de
recuperación. Esa sesión se hizo desde fuera del contenedor y no dejó
transcript ni historial de shell que se pueda revisar.
---
## Causa a — Contenedor recreado sin los volúmenes de autenticación
**Síntoma.** Al recrear `tmux-host`, `claude` volvía a pedir login y el
diálogo de confianza del folder. `--continue` no encontraba ninguna
conversación previa y arrancaba una en blanco.
**Diagnóstico.** El `docker run` que se usó para recrear el contenedor
se armó de memoria. Tenía solo 3 de los 5 volúmenes documentados en
[Sesión tmux persistente](../arquitectura/sesion-tmux-persistente.md)
**(reportado)**. Faltaban justamente los dos de autenticación y estado
de Claude Code:
| Volumen faltante | Qué se perdía |
|---|---|
| `/DATA/AppData/k3d-lab-v3/claude-code-home` → `/root/.claude` | Credenciales OAuth, historial de conversaciones (por eso `--continue` no encontraba nada), plugins y memoria |
| `/DATA/AppData/k3d-lab-v3/claude-code-home.json` → `/root/.claude.json` | Confianza del folder y el flag de Remote Control |
Sin esos mounts, `/root/.claude*` vive en el filesystem efímero del
contenedor, así que cada recreación arrancaba "de fábrica". El contenedor
se reconstruyó varias veces con el mismo comando incompleto antes de
comparar contra el doc de arquitectura **(reportado)**. `health.log`
muestra las sesiones recreándose desde cero a las 04:11Z, 04:40Z, 04:49Z,
05:03Z, 05:18Z, 06:08Z y 06:10Z **(verificado)**.
**Fix.** Recrear el contenedor con el `docker run` completo de la
sección *Contenedor: `tmux-host`* del doc de arquitectura. Los 5 volúmenes
van juntos, sin excepciones.
**Validación en vivo (verificado).** `docker inspect tmux-host` sobre el
contenedor final (creado `2026-09-26T06:10:55Z`, imagen
`tmux-host:2026-09-26-fix-real`) lista los 5 mounts:
```
/var/run/docker.sock -> /var/run/docker.sock
/DATA/AppData/k3d-lab-v3/claude-code-home -> /root/.claude
/DATA/AppData/k3d-lab-v3/claude-code-home.json -> /root/.claude.json
/DATA -> /DATA
/media/HDD-Storage -> /media/HDD-Storage
```
La sesión `lab` retomó la conversación anterior con `--continue`, sin
pedir de nuevo la confianza del folder.
!!! warning "Lección"
**Antes de improvisar un `docker run` de memoria, leer siempre el doc
de arquitectura que ya existe.** El comando completo y el motivo de
cada volumen ya estaban documentados en `sesion-tmux-persistente.md`.
El tiempo perdido reconstruyendo con un comando incompleto salió de no
abrirlo primero.
---
## Causa b — `set -e` heredado mataba al supervisor del healthcheck
**Síntoma.** `healthcheck.sh` corre en background y no tenía quién lo
relanzara si moría. Para cubrir eso se agregó a `entrypoint.sh` un
supervisor: un loop que lo relanza tras 10 s. Aun así, cuando
`healthcheck.sh` terminaba con error, no se relanzaba y el contenedor
seguía vivo sin healthcheck.
**Diagnóstico.** `entrypoint.sh` tenía `set -eu` desde antes. El
supervisor corre como función en un subshell (`supervise_healthcheck &`)
y hereda el `-e`. Cuando `healthcheck.sh` devolvía un código distinto de
cero, `set -e` mataba al propio subshell del supervisor *en esa misma
línea*, antes de llegar al `echo WARN` y al `sleep 10` que lo relanzaban.
Es decir, el supervisor moría justo en el único caso para el que existía.
**Paso en falso: el fix que nunca llegó al disco.** La primera
"corrección" (`set -eu` → `set -u`) nunca se guardó en el archivo, y la
imagen se reconstruyó dos veces con el contenido viejo **(reportado)**.
La evidencia sigue en el host **(verificado)**: las etiquetas
`tmux-host:2026-09-25-supervised` y `tmux-host:2026-09-25-supervised-v2`
apuntan a la **misma imagen, `ad7f6eff9537`**. Docker reutilizó todas las
capas porque el archivo no había cambiado. Recién después de confirmar
con `cat entrypoint.sh` que el cambio no estaba, se guardó de verdad y se
construyó `tmux-host:2026-09-26-fix-real` (`7e4c94bf0bc8`, 05:03Z).
**Fix.** En `scripts/tmux-host/entrypoint.sh`:
```diff
-set -eu
+set -u
-/usr/local/bin/healthcheck.sh &
+supervise_healthcheck() {
+ while true; do
+ /usr/local/bin/healthcheck.sh
+ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) WARN: healthcheck.sh salio inesperadamente (exit=$?) -- relanzando en 10s" \
+ >> /DATA/AppData/k3d-lab-v3/tmux-host/logs/health.log 2>/dev/null || true
+ sleep 10
+ done
+}
+supervise_healthcheck &
exec tail -f /dev/null
```
**Validación en vivo (verificado).** `health.log` registra el supervisor
actuando en un caso real. `healthcheck.sh` murió por SIGKILL, se registró
el aviso y se relanzó solo 10 segundos después:
```
2026-09-26T05:04:17Z WARN: healthcheck.sh salio inesperadamente (exit=137) -- relanzando en 10s
[2026-09-26T05:04:27Z] === healthcheck inicio (intervalo=1800s) ===
```
!!! warning "Lección"
**Verificar el archivo en disco (`cat`, `git diff`) ANTES de
reconstruir la imagen.** No hay que asumir que el editor guardó el
cambio. Dos etiquetas de imagen con el mismo ID indican que el build
no tuvo cambios.
---
## Causa c — IPv6 roto en el NAS: timeout de conexión a Anthropic
**Síntoma.** Con el contenedor ya bien montado, `claude` fallaba al
arrancar con `Unable to connect to Anthropic services` a los ~10 s.
**Diagnóstico** con `curl -v https://api.anthropic.com/` **(reportado)**:
1. El resolver devolvía primero la dirección IPv6 de `api.anthropic.com`.
2. La ruta IPv6 del NAS no sale a internet: el intento fallaba al
instante con `Network unreachable`.
3. El fallback automático a IPv4 terminaba funcionando, pero la conexión
completa tardaba **~14 s**. Eso es más que el timeout interno de
Claude Code (~10 s), así que el CLI se rendía antes de que el
fallback llegara a conectar.
**Primer intento: parche parcial.**
`NODE_OPTIONS=--dns-result-order=ipv4first` le indica a Node que prefiera
IPv4 al resolver. Ayudó, pero no resultó confiable en todos los casos
**(reportado)**, y además solo corregía el proceso de Node, no la red del
host. Se descartó y no quedó en la imagen: el contenedor final no tiene
`NODE_OPTIONS` en su entorno **(verificado)**.
**Fix de raíz.** Deshabilitar IPv6 a nivel de sistema operativo del NAS,
en caliente y persistente:
```bash
# En caliente
sysctl -w net.ipv6.conf.all.disable_ipv6=1
sysctl -w net.ipv6.conf.default.disable_ipv6=1
# Persistente: /etc/sysctl.d/99-disable-ipv6.conf del host
# (NO /etc/sysctl.conf -- ver "Validación tras reinicio real" abajo)
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
```
Esto se tiene que hacer **en el host, no dentro del contenedor**.
`tmux-host` corre con `--network host`, así que comparte el network
namespace del NAS. Docker no permite fijar sysctls de red
(`--sysctl net.*`) en un contenedor con `--network host`, porque
cambiarían la red de todo el host.
**Validación en vivo (verificado, 2026-09-26 ~06:40Z):**
```
$ sysctl net.ipv6.conf.all.disable_ipv6 net.ipv6.conf.default.disable_ipv6
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
$ getent ahosts api.anthropic.com
160.79.104.10 STREAM api.anthropic.com # solo IPv4, sin AAAA
$ curl -w 'connect=%{time_connect}s total=%{time_total}s' https://api.anthropic.com/
ip=160.79.104.10 connect=0.035s total=0.65s # antes: ~14 s
```
`/etc/sysctl.conf` en el host contiene las dos líneas (escrito
2026-09-26 05:55:53Z). En ZimaOS, `/` es un `squashfs` de solo lectura,
pero `/etc` es un overlay con capa de escritura en
`/mnt/overlay/upper_etc` (ext4 en `/dev/sdc7`), así que el archivo
persiste.
### Validación tras reinicio real: `/etc/sysctl.conf` no alcanzaba (2026-09-26)
Se reinició el NAS a propósito (~17:13Z) para probar la persistencia.
Resultado **(verificado)**:
- `/etc/sysctl.conf` **sí** sobrevivió al reinicio con las dos líneas
(el overlay de `/etc` persiste).
- Pero `sysctl net.ipv6.conf.all.disable_ipv6` volvió a dar **`0`**: el
archivo existía y **nunca se aplicó** al arrancar.
**Causa.** En ZimaOS, `systemd-sysctl` solo carga `/etc/sysctl.d/*.conf`
(y `/usr/lib/sysctl.d/`, `/lib/sysctl.d/`). Otras distros traen un
symlink `/etc/sysctl.d/99-sysctl.conf -> ../sysctl.conf` que hace que
`/etc/sysctl.conf` también se lea al arrancar. **ZimaOS no lo trae**, así
que escribir en `/etc/sysctl.conf` funciona solo en caliente (con
`sysctl -p`) y no sobrevive un reinicio. Nada avisa del problema: el
archivo está, el contenido es correcto y el valor igual vuelve a `0`.
**Por qué no se rompió enseguida.** Tras el reinicio el host no tenía
ninguna IPv6 global utilizable, así que `getaddrinfo` no devolvía la AAAA
de `api.anthropic.com` y todo salía por IPv4. Pero `curl -6` seguía
fallando (`Could not connect to server`). Si el router vuelve a entregar
una IPv6 global por SLAAC, vuelve el timeout. Funcionaba por casualidad,
no porque estuviera corregido.
**Fix.** Mover la configuración a un drop-in que ZimaOS sí lee:
`/etc/sysctl.d/99-disable-ipv6.conf` (mismas dos líneas).
Para aplicarlo en caliente sin reiniciar: dentro de `tmux-host`,
`/proc/sys` es de solo lectura (`sysctl -w` → `Read-only file system`).
Hace falta un contenedor privilegiado de un solo uso en la red del host,
o `sudo sysctl -p` directo en el host:
```bash
docker run --rm --privileged --network host \
-v /etc/sysctl.d:/etc/sysctl.d:ro \
alpine sysctl -p /etc/sysctl.d/99-disable-ipv6.conf
```
**Validado en vivo (verificado, 2026-09-26 ~19:15Z):**
`disable_ipv6 = 1` en `all` y `default`, ninguna dirección IPv6 global en
el host y `curl` a `api.anthropic.com` por IPv4 (`connect=0.035s`).
!!! warning "Lección"
"El archivo de configuración persistió" no es lo mismo que "la
configuración se aplica al arrancar". La única validación real de
algo persistente es **reiniciar y medir el valor efectivo**, no
revisar que el archivo siga ahí.
!!! success "Confirmado con un segundo reinicio real (2026-09-26 ~19:37Z)"
Tras reiniciar el NAS de nuevo, sin correr ningún `sysctl -p` a mano:
`disable_ipv6 = 1` en `all` y `default`, y `curl` a
`api.anthropic.com` por IPv4 (`connect=0.035s`). El drop-in de
`/etc/sysctl.d/` **sí** se aplica al arrancar. El único IPv6 que queda
es la ULA de `virbr0` (red interna de libvirt, `NO-CARRIER`,
`tentative`), que no sale a internet y no afecta.
Sigue sin verificarse si una **actualización de ZimaOS** pisa
`upper_etc`. Después de cada update del SO, revisar
`sysctl net.ipv6.conf.all.disable_ipv6`.
---
## Causa d — Token OAuth vencido: Remote Control desconectado sin recuperación automática
**Síntoma.** Con todo lo anterior resuelto, las sesiones remotas
*seguían* sin aparecer en claude.ai. Este era el síntoma original de la
noche.
**Diagnóstico (verificado).** Ambos procesos `claude --remote-control
--continue` estaban corriendo (`ps`), y el healthcheck había recreado
las dos sesiones sin problema (`health.log`, 06:10:58Z). Así que el
auto-arranque funcionaba. El contenido de los panes de tmux mostró la
causa:
- Sesión `lab`: `Login expired · Please run /login`
- Sesión `cloud-practice`:
`Remote Control disconnected — OAuth token unavailable — run /login to restore Remote Control`
El token OAuth de Claude Code había vencido. No había ningún problema de
red, de contenedor ni de volúmenes. **Esta causa no tenía relación con
ninguna de las tres anteriores.** Eliminar el contenedor nunca podría
haberla resuelto.
**Fix.**
1. `/login` en **una** sesión. Las credenciales viven en
`/root/.claude/.credentials.json`, que es el volumen compartido por
todas las sesiones del contenedor.
2. `/remote-control` → **1. Enable Remote Control** en **cada** sesión ya
abierta. Un proceso `claude` que ya estaba corriendo **no** se
reconecta solo al renovarse el token. Hay que reactivarlo en cada uno.
Para una sesión a la que no estás adjunto:
```bash
tmux send-keys -t cloud-practice '/remote-control' Enter
# aparece el menú con "1. Enable Remote Control" ya seleccionado
tmux send-keys -t cloud-practice Enter
```
**Validación en vivo (verificado).** El pane de `cloud-practice` pasó a
mostrar `/remote-control is active · Continue here, on your phone, or at
https://claude.ai/code/session_…`, y en `lab` se activó con
`/remote-control` después del `/login`. Las dos sesiones volvieron a
aparecer en claude.ai.
!!! tip "Diferencia clave con las otras causas"
El healthcheck **sí** recrea sola una sesión tmux caída, y `--continue`
retoma la conversación. Un **token vencido no se recupera solo**: hace
falta que una persona se reautentique (`/login`) y reactive Remote
Control (`/remote-control`) en cada sesión abierta. Recrear el
contenedor no lo arregla. Con los volúmenes bien montados, el
contenedor nuevo lee el mismo token vencido.
---
## Cronología resumida
Horas en UTC, según `health.log`, `docker images` y los timestamps de
archivos cuando existen.
| Hora (UTC) | Qué pasó | ¿Resolvió el síntoma original? |
|---|---|---|
| antes de 04:11 | Remote Control se desconecta porque vence el token OAuth (causa **d**, todavía sin identificar) | — |
| ~04:10 | Se elimina `tmux-host` creyendo que es la causa | No |
| 04:11 → 05:18 | Varias recreaciones con un `docker run` de memoria, sin los mounts de auth: se pide login y confianza de nuevo y `--continue` arranca vacío (causa **a**) | No, y además empeoró |
| 04:39 | Se agrega el supervisor del healthcheck; builds `supervised` y `supervised-v2`, **misma imagen `ad7f6eff9537`** (el fix de `set -e` no estaba guardado) | No |
| 05:03 | Se confirma con `cat`, se guarda de verdad y se construye `fix-real` (`7e4c94bf0bc8`) con `set -u` (causa **b**) | No |
| 05:04 | El supervisor relanza `healthcheck.sh` tras un `exit=137`: primera prueba real | No |
| hasta ~05:55 | `claude` falla con `Unable to connect to Anthropic services`; con `curl -v` se ve IPv6 unreachable y ~14 s de fallback; se prueba `NODE_OPTIONS` (parcial) | No |
| 05:55 | Se deshabilita IPv6 en el host, en caliente y en `/etc/sysctl.conf` (causa **c**) | No |
| 06:10 | Contenedor final: `fix-real` + los 5 volúmenes, recreado según el doc de arquitectura (causa **a** cerrada) | No: Remote Control sigue desconectado |
| ~06:20 | Los panes muestran `Login expired` / `OAuth token unavailable` → `/login` + `/remote-control` en cada sesión (causa **d**) | **Sí** |
**Camino corto para la próxima vez.** Si "no aparecen las sesiones
remotas", **antes de tocar el contenedor** mirar el pane de cada sesión:
```bash
for s in $(tmux ls -F '#S'); do echo "== $s"; tmux capture-pane -p -t "$s" | grep -v '^\s*$' | tail -5; done
```
Si aparece `Login expired` u `OAuth token unavailable`, la solución es
`/login` + `/remote-control`. No hace falta nada más.
## Lección general
Un solo síntoma ("no toma las sesiones remotas") puede tener **varias
causas independientes superpuestas**. En este caso, tres de ellas las
provocó el propio intento de arreglo. Que la primera causa real
encontrada se haya resuelto no significa que era *la* causa del síntoma.
Lo que destrabó el incidente fue seguir verificando con evidencia
concreta después de cada fix, en vez de asumir que "ya quedó": el
contenido de los panes, `health.log`, `docker inspect`, `docker images`
(IDs repetidos) y `curl -v`. Cada una de las cuatro causas se confirmó
con un dato observable, no por deducción.
## Pendientes
Todos cerrados el 2026-09-26:
- **Hecho: commitear el fix de `entrypoint.sh`.** Mergeado a `main` del
repo `scripts` (`510cc41`). Antes se verificó que `entrypoint.sh` y
`healthcheck.sh` del repo fueran idénticos (md5) a los de la imagen
`fix-real` en producción.
- **Hecho: validar IPv6 tras un reinicio real.** El primer reinicio
**falló**: `/etc/sysctl.conf` no se aplica al arrancar en ZimaOS. Se movió
a `/etc/sysctl.d/99-disable-ipv6.conf`, y un segundo reinicio confirmó
que ahora sí se aplica solo (ver la causa c).
- **Hecho: limpiar las etiquetas de imagen viejas de `tmux-host`.** Quedan
solo `2026-09-26-fix-real` (vigente) y `2026-09-20-uv` (rollback).
Queda una sola vigilancia abierta, sin fecha: después de cada actualización
de ZimaOS, revisar que `/etc/sysctl.d/99-disable-ipv6.conf` siga existiendo
y que `disable_ipv6` siga en `1`.
+4
View File
@@ -86,6 +86,7 @@ nav:
- Flujo GitOps: arquitectura/gitops-flujo.md
- Agente de monitoreo: arquitectura/agente-monitoreo.md
- Sesión tmux persistente: arquitectura/sesion-tmux-persistente.md
- Plugins y subagentes: arquitectura/plugins-y-subagentes.md
- Glosario: arquitectura/glosario.md
- Decisiones (ADRs):
- "0001 · Por qué k3d": decisiones/0001-por-que-k3d.md
@@ -99,6 +100,8 @@ nav:
- "0009 · Checkout real": decisiones/0009-checkout-real.md
- "0010 · Pago y envío reales": decisiones/0010-pago-envio-reales-y-storefront.md
- "0011 · Ambiente de Staging": decisiones/0011-ambiente-staging.md
- "0012 · Práctica multi-cloud GCP/Azure": decisiones/0012-practica-multicloud-gcp-azure.md
- "0013 · Iniciativas de clientes": decisiones/0013-iniciativas-clientes.md
- Plantilla ADR: decisiones/plantilla-adr.md
- Playbooks:
- Crashloop Medusa: playbooks/incidente-crashloop-medusa.md
@@ -111,6 +114,7 @@ nav:
- Realm HTTP y Cosign: playbooks/incidente-cosign-realm-http-tunnel-2026-08.md
- Contención de recursos Gitea/runner: playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md
- Blackhole MTU cross-node: playbooks/incidente-mtu-blackhole-cross-node-2026-08.md
- "Cadena de fallos tmux-host": playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md
- "Migración a platform-templates": playbooks/migracion-platform-templates-2026-08.md
- Aprendizajes:
- Notas sueltas: aprendizajes/notas-sueltas.md
@@ -25,7 +25,7 @@ spec:
runAsNonRoot: false
containers:
- name: app
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.269
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.283
# 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
@@ -26,11 +26,11 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.273
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.286
env:
# Ver comentario en workloads/ecommerce/frontend/patches/deployment.yaml
- name: APP_VERSION
value: v1.0.273
value: v1.0.286
- name: HEADLESS_PROVIDER
value: medusa
# Backend COMPARTIDO con Producción a propósito (decisión del
@@ -0,0 +1,11 @@
import { getMyOrders } from "@/lib/headless/providers/medusa-reviews";
export async function GET(request: Request) {
const header = request.headers.get("authorization") ?? "";
if (!header.startsWith("Bearer ")) return Response.json({ error: "No autenticado" }, { status: 401 });
try {
return Response.json({ orders: await getMyOrders(header.slice(7)) });
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: (error as { status?: number }).status ?? 400 });
}
}
@@ -0,0 +1,11 @@
import { getMyReviews } from "@/lib/headless/providers/medusa-reviews";
export async function GET(request: Request) {
const header = request.headers.get("authorization") ?? "";
if (!header.startsWith("Bearer ")) return Response.json({ error: "No autenticado" }, { status: 401 });
try {
return Response.json({ reviews: await getMyReviews(header.slice(7)) });
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: (error as { status?: number }).status ?? 400 });
}
}
@@ -0,0 +1,30 @@
import { createReview, getProductReviews } from "@/lib/headless/providers/medusa-reviews";
function bearer(request: Request): string | null {
const header = request.headers.get("authorization") ?? "";
return header.startsWith("Bearer ") ? header.slice(7) : null;
}
export async function GET(request: Request) {
const productId = new URL(request.url).searchParams.get("product_id");
if (!productId) return Response.json({ error: "product_id es obligatorio" }, { status: 400 });
try {
return Response.json(await getProductReviews(productId));
} catch {
// Backend sin el modulo todavia (o caido): la ficha muestra "sin
// reseñas" en vez de romperse.
return Response.json({ reviews: [], average: 0, count: 0 });
}
}
export async function POST(request: Request) {
const token = bearer(request);
if (!token) return Response.json({ error: "Inicia sesión para dejar una reseña" }, { status: 401 });
const body = await request.json().catch(() => ({}));
try {
return Response.json(await createReview(token, body), { status: 201 });
} catch (error) {
const status = (error as { status?: number }).status ?? 400;
return Response.json({ error: (error as Error).message }, { status });
}
}
@@ -0,0 +1,9 @@
import { getReviewSummary } from "@/lib/headless/providers/medusa-reviews";
export async function GET() {
try {
return Response.json({ summary: await getReviewSummary() });
} catch {
return Response.json({ summary: {} });
}
}
+7 -1
View File
@@ -22,6 +22,11 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
const category = single(params.category) || "Todos";
const collection = single(params.collection);
const query = single(params.q);
const taxonomy = {
franquicia: single(params.franquicia),
saga: single(params.saga),
personaje: single(params.personaje),
};
// Los links de navbar/tiles apuntan a categorias raiz (ej. "Mochilas y
// morrales"), pero ningun producto tiene esa categoria asignada
@@ -39,13 +44,14 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
return (
<CatalogClient
key={`${category}:${collection}:${query}`}
key={`${category}:${collection}:${query}:${taxonomy.franquicia}:${taxonomy.saga}:${taxonomy.personaje}`}
products={products}
categories={categories}
categoryGroups={categoryGroups}
initialCategory={category}
initialCollection={collection}
initialQuery={query}
initialTaxonomy={taxonomy}
/>
);
}
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { OrdersClient } from "@/components/account/orders-client";
export const metadata: Metadata = {
title: "Mis pedidos",
description: "Tus pedidos en ARI Shopping y las reseñas de lo que compraste.",
robots: { index: false },
};
export default function OrdersPage() {
return <OrdersClient />;
}
@@ -1,7 +1,9 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { CardRating } from "@/components/product/card-rating";
import { ProductCard } from "@/components/product/product-card";
import { ProductReviews } from "@/components/product/product-reviews";
import { ProductCarousel } from "@/components/product/product-carousel";
import { ProductGallery } from "@/components/product/product-gallery";
import { ProductPurchase } from "@/components/product/product-purchase";
@@ -69,6 +71,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
<div className="min-w-0">
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">{product.category} · {product.collection}</span>
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">{product.name}</h1>
<CardRating productId={product.id} href="#resenas" />
<p className="mt-5 text-sm leading-6 text-slate-600">{product.description}</p>
{/* Antes escondido detrás de un click en el acordeón --
siempre visible, estilo Amazon, con los mismos datos
@@ -87,6 +90,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
<AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent>
</AccordionItem>
</Accordion>
<ProductReviews productId={product.id} />
</div>
{/* Buy box estilo Amazon: precio/variantes/carrito en su propia
tarjeta, sticky desde xl mientras el resto de la pagina
@@ -65,7 +65,7 @@ spec:
memory: 64Mi
- name: migrations
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.265
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.287
imagePullPolicy: IfNotPresent
command:
- node
@@ -86,7 +86,7 @@ spec:
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.265
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.287
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
@@ -46,7 +46,7 @@ export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenCha
<h2 className="mt-3 text-3xl font-black">Hola, {customer.firstName ?? customer.email}</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">{customer.email}</p>
<div className="mt-6 grid gap-3">
<Button asChild variant="outline"><Link href="/catalog" onClick={() => onOpenChange(false)}><PackageCheck />Ver mis opciones de compra</Link></Button>
<Button asChild variant="outline"><Link href="/cuenta/pedidos" onClick={() => onOpenChange(false)}><PackageCheck />Mis pedidos y reseñas</Link></Button>
<Button variant="ghost" onClick={logout}><LogOut className="size-4" />Cerrar sesión</Button>
</div>
</>
@@ -0,0 +1,276 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Clock, PackageCheck, Star } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from "react";
import { AccountDialog } from "@/components/account/account-dialog";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { formatMoney } from "@/lib/utils";
import { useCustomerStore } from "@/store/customer-store";
type OrderItem = { productId: string; title: string; thumbnail: string | null; handle: string | null; quantity: number };
type Order = { id: string; displayId: number | null; createdAt: string; status: string; total: number | null; currency: string; items: OrderItem[] };
type MyReview = { id: string; product_id: string; status: "pending" | "approved" | "rejected" };
const REVIEW_LABEL: Record<MyReview["status"], string> = {
pending: "Reseña enviada · en revisión",
approved: "Reseña publicada",
rejected: "Reseña no publicada",
};
// "Mis pedidos": historial real del cliente (Store API) y, por cada
// producto comprado, el boton para calificarlo. Solo desde aca se puede
// reseñar: el backend exige que el pedido sea del cliente y contenga el
// producto ("Compra verificada").
export function OrdersClient() {
const { token, customer, hasHydrated } = useCustomerStore();
const [orders, setOrders] = useState<Order[] | null>(null);
const [reviews, setReviews] = useState<MyReview[]>([]);
const [error, setError] = useState<string | null>(null);
const [accountOpen, setAccountOpen] = useState(false);
const [reviewing, setReviewing] = useState<{ order: Order; item: OrderItem } | null>(null);
const load = useCallback(async () => {
if (!token) return;
setError(null);
try {
const headers = { Authorization: `Bearer ${token}` };
const [ordersRes, reviewsRes] = await Promise.all([fetch("/api/customer/orders", { headers }), fetch("/api/reviews/me", { headers })]);
const ordersData = await ordersRes.json();
if (!ordersRes.ok) throw new Error(ordersData.error ?? "No pudimos cargar tus pedidos");
const reviewsData = await reviewsRes.json().catch(() => ({ reviews: [] }));
setOrders(ordersData.orders);
setReviews(reviewsRes.ok ? reviewsData.reviews : []);
} catch (loadError) {
setError((loadError as Error).message);
setOrders([]);
}
}, [token]);
useEffect(() => {
load();
}, [load]);
if (!hasHydrated) return <Shell><p className="text-slate-500">Cargando…</p></Shell>;
if (!customer || !token) {
return (
<Shell>
<div className="rounded-2xl border border-slate-200 p-8 text-center">
<p className="text-lg font-black text-ari-marino">Inicia sesión para ver tus pedidos</p>
<p className="mt-2 text-sm text-slate-500">Desde aquí puedes calificar los productos que compraste.</p>
<Button variant="primary" className="mt-5" onClick={() => setAccountOpen(true)}>Iniciar sesión</Button>
</div>
<AccountDialog open={accountOpen} onOpenChange={setAccountOpen} />
</Shell>
);
}
const reviewFor = (productId: string) => reviews.find((review) => review.product_id === productId);
return (
<Shell>
{error && <p className="mb-4 rounded-2xl bg-rose-50 p-4 text-sm font-bold text-rose-700">{error}</p>}
{orders === null ? (
<p className="text-slate-500">Cargando tus pedidos…</p>
) : orders.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 p-8 text-center">
<p className="font-black text-ari-marino">Todavía no tienes pedidos</p>
<Button asChild variant="primary" className="mt-4"><Link href="/catalog">Ver catálogo</Link></Button>
</div>
) : (
<ul className="grid gap-5">
{orders.map((order) => (
<li key={order.id} className="rounded-2xl border border-slate-200 p-5 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 pb-3 text-sm">
<span className="font-black text-ari-marino">Pedido #{order.displayId ?? order.id.slice(-6)}</span>
<span className="text-slate-500">
{new Date(order.createdAt).toLocaleDateString("es-CO", { year: "numeric", month: "long", day: "numeric" })}
{order.total !== null && <> · {formatMoney(order.total, order.currency)}</>}
{order.status === "canceled" && <span className="ml-2 font-bold text-rose-600">Cancelado</span>}
</span>
</div>
<ul className="mt-3 grid gap-3">
{order.items.map((item) => {
const review = reviewFor(item.productId);
return (
<li key={`${order.id}-${item.productId}`} className="flex flex-wrap items-center gap-4">
<div className="relative size-16 shrink-0 overflow-hidden rounded-xl bg-slate-50">
{item.thumbnail && <Image src={item.thumbnail} alt="" fill sizes="64px" className="object-contain" unoptimized />}
</div>
<div className="min-w-0 flex-1">
{item.handle ? (
<Link href={`/product/${item.handle}`} className="line-clamp-2 font-bold text-slate-900 hover:text-ari-coral">{item.title}</Link>
) : (
<span className="line-clamp-2 font-bold text-slate-900">{item.title}</span>
)}
<span className="text-xs text-slate-500">Cantidad: {item.quantity}</span>
</div>
{review ? (
<span className={`inline-flex items-center gap-1.5 text-xs font-bold ${review.status === "approved" ? "text-emerald-700" : review.status === "pending" ? "text-amber-700" : "text-slate-500"}`}>
{review.status === "pending" ? <Clock className="size-3.5" /> : <PackageCheck className="size-3.5" />}
{REVIEW_LABEL[review.status]}
</span>
) : order.status === "canceled" ? null : (
<Button size="sm" variant="primary" onClick={() => setReviewing({ order, item })}>
<Star className="size-4" />Calificar
</Button>
)}
</li>
);
})}
</ul>
</li>
))}
</ul>
)}
<ReviewDialog
target={reviewing}
token={token}
onClose={() => setReviewing(null)}
onSubmitted={() => {
setReviewing(null);
load();
}}
/>
</Shell>
);
}
function Shell({ children }: { children: ReactNode }) {
return (
<div className="mx-auto max-w-3xl px-4 py-10">
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Mi cuenta</span>
<h1 className="font-display mt-2 text-4xl font-bold tracking-tight text-ari-marino">Mis pedidos</h1>
<div className="mt-8">{children}</div>
</div>
);
}
function ReviewDialog({
target,
token,
onClose,
onSubmitted,
}: {
target: { order: Order; item: OrderItem } | null;
token: string;
onClose: () => void;
onSubmitted: () => void;
}) {
const [rating, setRating] = useState(0);
const [hover, setHover] = useState(0);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!target) return;
setRating(0);
setHover(0);
setTitle("");
setContent("");
setError(null);
}, [target]);
async function submit(event: FormEvent) {
event.preventDefault();
if (!target) return;
if (rating < 1) return setError("Elige de 1 a 5 estrellas");
if (content.trim().length < 10) return setError("Cuéntanos un poco más (mínimo 10 caracteres)");
setSending(true);
setError(null);
try {
const response = await fetch("/api/reviews", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ product_id: target.item.productId, order_id: target.order.id, rating, title: title.trim() || undefined, content: content.trim() }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error ?? "No pudimos enviar tu reseña");
onSubmitted();
} catch (submitError) {
setError((submitError as Error).message);
} finally {
setSending(false);
}
}
const shown = hover || rating;
return (
<Dialog open={target !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-lg p-6 sm:p-8">
<DialogPrimitive.Title className="pr-10 text-2xl font-black text-ari-marino">Califica tu compra</DialogPrimitive.Title>
<DialogPrimitive.Description className="mt-1 line-clamp-2 text-sm text-slate-500">{target?.item.title}</DialogPrimitive.Description>
<form onSubmit={submit} className="mt-6 grid gap-4">
<fieldset>
<legend className="mb-2 text-sm font-black">Tu calificación</legend>
{/* Patron ARIA de radiogroup (no toggles sueltos): el lector de
pantalla anuncia "3 estrellas, seleccionado, 3 de 5", un solo
tab-stop y flechas para cambiar -- con aria-pressed por
estrella solo se anunciaba la estrella exacta, no la
calificacion (reportado por frontend-critic). */}
<div
role="radiogroup"
aria-label="Tu calificación"
className="flex gap-1"
onMouseLeave={() => setHover(0)}
onKeyDown={(event) => {
const delta = event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowDown" ? -1 : 0;
if (!delta) return;
event.preventDefault();
// Como un radiogroup nativo: avanza desde la opcion con foco
// (si aun no hay calificacion elegida) o desde la elegida.
const buttons = [...event.currentTarget.querySelectorAll("button")];
const focused = buttons.indexOf(document.activeElement as HTMLButtonElement) + 1;
const next = Math.min(5, Math.max(1, (rating || focused || 0) + delta));
setRating(next);
setError(null);
(event.currentTarget.querySelectorAll("button")[next - 1] as HTMLButtonElement | undefined)?.focus();
}}
>
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
role="radio"
aria-checked={rating === value}
aria-label={`${value} ${value === 1 ? "estrella" : "estrellas"}`}
tabIndex={rating ? (rating === value ? 0 : -1) : value === 1 ? 0 : -1}
onMouseEnter={() => setHover(value)}
onClick={() => { setRating(value); setError(null); }}
className="rounded-md p-0.5 transition hover:scale-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ari-marino"
>
<Star className={`size-9 ${value <= shown ? "fill-amber-500 text-amber-600" : "text-slate-400"}`} />
</button>
))}
</div>
</fieldset>
<label className="grid gap-1.5 text-sm font-black">
Título (opcional)
<Input value={title} onChange={(event) => setTitle(event.target.value)} maxLength={80} placeholder="Ej: ¡A mi hijo le encantó!" />
</label>
<label className="grid gap-1.5 text-sm font-black">
Tu reseña
<textarea
value={content}
onChange={(event) => setContent(event.target.value)}
rows={5}
maxLength={2000}
placeholder="¿Qué te pareció el producto? ¿Llegó bien?"
className="rounded-2xl border border-slate-200 bg-white p-4 text-sm font-normal text-slate-950 outline-none focus:border-ari-coral focus:ring-4 focus:ring-ari-coral/15"
/>
</label>
<p className="text-xs text-slate-500">Tu reseña se publica después de que la revisemos. Mostramos tu nombre y la inicial de tu apellido.</p>
{error && <p className="rounded-xl bg-rose-50 p-3 text-sm font-bold text-rose-700">{error}</p>}
<Button type="submit" variant="primary" size="lg" disabled={sending}>{sending ? "Enviando…" : "Enviar reseña"}</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -8,6 +8,7 @@ import { ProductCard } from "@/components/product/product-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { Category, FacetDistribution, Product } from "@/lib/headless/types";
import { matchesTaxonomy, taxonomyValues, type TaxonomyFilter } from "@/lib/taxonomy";
// Cada cuantas tarjetas de producto se intercala un tile promocional en
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
@@ -23,12 +24,19 @@ type CatalogClientProps = {
initialCategory?: string;
initialCollection?: string;
initialQuery?: string;
initialTaxonomy?: TaxonomyFilter;
};
// Sin guiones ni espacios: "spiderman" encuentra "Spider-Man".
function normalizeSearch(value: string): string {
return value.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[\s-]+/g, "");
}
function matchesQuery(product: Product, query: string): boolean {
if (!query) return true;
const search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase();
return search.includes(query.toLowerCase());
const characters = [...taxonomyValues(product, "franquicia"), ...taxonomyValues(product, "saga"), ...taxonomyValues(product, "personaje")];
const search = normalizeSearch([product.name, product.category, product.collection, product.brand, ...characters].join(" "));
return search.includes(normalizeSearch(query));
}
function matchesCategory(product: Product, allowedCategories: string[]): boolean {
return allowedCategories.includes(product.category);
@@ -69,8 +77,18 @@ export function CatalogClient({
initialCategory = "Todos",
initialCollection = "",
initialQuery = "",
initialTaxonomy = {},
}: CatalogClientProps) {
const [query, setQuery] = useState(initialQuery);
// Franquicia/saga/personaje (lib/taxonomy.ts): se filtra 100% client-side
// sobre los tags -- Meilisearch no tiene estos campos como facetas.
const [taxonomy, setTaxonomy] = useState<TaxonomyFilter>(initialTaxonomy);
const taxonomyActive = Boolean(taxonomy.franquicia || taxonomy.saga || taxonomy.personaje);
// El titulo solo refleja la franquicia/saga/personaje que viene en la URL
// (menu "Personajes"), igual que la barra de navegacion: si reaccionara
// al filtro lateral (que no cambia la URL), el H1 diria "Marvel" con
// "Todo" marcado activo arriba (reportado por frontend-critic).
const urlTaxonomyTitle = initialTaxonomy.personaje || initialTaxonomy.saga || initialTaxonomy.franquicia || "";
const [category, setCategory] = useState(initialCategory);
const [collection, setCollection] = useState(initialCollection);
const [brand, setBrand] = useState("Todas");
@@ -96,13 +114,30 @@ export function CatalogClient({
setAge("Todas");
setSort("featured");
setMaxPrice(maxCatalogPrice);
}, [initialCategory, initialCollection, initialQuery, maxCatalogPrice]);
setTaxonomy({ franquicia: initialTaxonomy.franquicia, saga: initialTaxonomy.saga, personaje: initialTaxonomy.personaje });
}, [initialCategory, initialCollection, initialQuery, maxCatalogPrice, initialTaxonomy.franquicia, initialTaxonomy.saga, initialTaxonomy.personaje]);
// Valores de franquicia/personaje presentes en el catalogo. Los
// personajes se acotan a la franquicia elegida (si hay una), para no
// ofrecer "Spider-Man" mientras se mira Disney.
const franchises = useMemo(() => ["Todas", ...new Set(products.flatMap((product) => taxonomyValues(product, "franquicia")))], [products]);
const characters = useMemo(
() => [
"Todos",
...new Set(
products
.filter((product) => matchesTaxonomy(product, { franquicia: taxonomy.franquicia, saga: taxonomy.saga }))
.flatMap((product) => taxonomyValues(product, "personaje")),
),
],
[products, taxonomy.franquicia, taxonomy.saga],
);
// Si `category` es una raiz con hijos (ej. "Mochilas y morrales"), se
// expande a la lista de nombres reales de sus hijos -- ningun producto
// tiene la raiz asignada directamente. Si no es una raiz conocida, se
// trata como el nombre de categoria exacto de siempre.
const allowedCategories = categoryGroups[category] ?? [category];
const allowedCategories = useMemo(() => categoryGroups[category] ?? [category], [categoryGroups, category]);
const breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]);
// No promociona la misma categoria raiz que el usuario ya esta viendo.
const promoRoots = useMemo(() => {
@@ -120,7 +155,8 @@ export function CatalogClient({
matchesCollection(product, collection) &&
matchesBrand(product, brand) &&
matchesAge(product, age) &&
matchesPrice(product, maxPrice),
matchesPrice(product, maxPrice) &&
matchesTaxonomy(product, taxonomy),
)
.sort((a, b) =>
sort === "name"
@@ -129,7 +165,7 @@ export function CatalogClient({
? Number(b.isNew) - Number(a.isNew)
: Number(b.featured) - Number(a.featured),
),
[age, allowedCategories, brand, category, collection, maxPrice, products, query, sort],
[age, allowedCategories, brand, category, collection, maxPrice, products, query, sort, taxonomy],
);
// Intercala un tile promocional cada PROMO_TILE_INTERVAL productos,
@@ -161,7 +197,7 @@ export function CatalogClient({
// la busqueda en vivo todavia no respondio.
const fallbackCategoryCounts = useMemo(() => {
const base = products.filter(
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice),
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, taxonomy),
);
const counts: Record<string, number> = { Todos: base.length };
for (const name of categories) {
@@ -170,7 +206,7 @@ export function CatalogClient({
counts[name] = base.filter((product) => matchesCategory(product, allowed)).length;
}
return counts;
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query, taxonomy]);
const fallbackBrandCounts = useMemo(() => {
const base = products.filter(
@@ -179,7 +215,8 @@ export function CatalogClient({
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) &&
matchesAge(product, age) &&
matchesPrice(product, maxPrice),
matchesPrice(product, maxPrice) &&
matchesTaxonomy(product, taxonomy),
);
const counts: Record<string, number> = { Todas: base.length };
for (const value of brands) {
@@ -187,7 +224,7 @@ export function CatalogClient({
counts[value] = base.filter((product) => product.brand === value).length;
}
return counts;
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query, taxonomy]);
const fallbackAgeCounts = useMemo(() => {
const base = products.filter(
@@ -196,7 +233,8 @@ export function CatalogClient({
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) &&
matchesBrand(product, brand) &&
matchesPrice(product, maxPrice),
matchesPrice(product, maxPrice) &&
matchesTaxonomy(product, taxonomy),
);
const counts: Record<string, number> = { Todas: base.length };
for (const value of ages) {
@@ -204,7 +242,7 @@ export function CatalogClient({
counts[value] = base.filter((product) => product.ageRange === value).length;
}
return counts;
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]);
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query, taxonomy]);
// "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe
// como valor indexado en Meilisearch (ningun producto lo tiene como
@@ -296,18 +334,33 @@ export function CatalogClient({
}, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]);
const categoryCounts = useMemo(
() => (liveFacets.category ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal],
() => (liveFacets.category && !taxonomyActive ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal, taxonomyActive],
);
const brandCounts = useMemo(
() => (liveFacets.brand ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal],
() => (liveFacets.brand && !taxonomyActive ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal, taxonomyActive],
);
const ageCounts = useMemo(
() => (liveFacets.ageRange ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
[fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal],
() => (liveFacets.ageRange && !taxonomyActive ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
[fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal, taxonomyActive],
);
// Conteos de franquicia/personaje: siempre client-side (no son facetas
// de Meilisearch), excluyendo su propia dimension como el resto.
const franchiseCounts = useMemo(() => {
const base = products.filter((product) => matchesQuery(product, query) && (category === "Todos" || matchesCategory(product, allowedCategories)) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, { saga: taxonomy.saga, personaje: taxonomy.personaje }));
const counts: Record<string, number> = { Todas: base.length };
for (const value of franchises) if (value !== "Todas") counts[value] = base.filter((product) => taxonomyValues(product, "franquicia").includes(value)).length;
return counts;
}, [age, allowedCategories, brand, category, collection, franchises, maxPrice, products, query, taxonomy.saga, taxonomy.personaje]);
const characterCounts = useMemo(() => {
const base = products.filter((product) => matchesQuery(product, query) && (category === "Todos" || matchesCategory(product, allowedCategories)) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, { franquicia: taxonomy.franquicia, saga: taxonomy.saga }));
const counts: Record<string, number> = { Todos: base.length };
for (const value of characters) if (value !== "Todos") counts[value] = base.filter((product) => taxonomyValues(product, "personaje").includes(value)).length;
return counts;
}, [age, allowedCategories, brand, category, characters, collection, maxPrice, products, query, taxonomy.franquicia, taxonomy.saga]);
const reset = () => {
setQuery("");
setCategory("Todos");
@@ -316,6 +369,7 @@ export function CatalogClient({
setAge("Todas");
setMaxPrice(maxCatalogPrice);
setSort("featured");
setTaxonomy({});
};
return (
@@ -343,9 +397,9 @@ export function CatalogClient({
</nav>
)}
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">
Catálogo completo
{urlTaxonomyTitle ? "Personajes" : "Catálogo completo"}
</span>
<h1 className="font-display mt-2 text-5xl font-bold tracking-tight text-ari-marino">Todos los tesoros</h1>
<h1 className="font-display mt-2 text-5xl font-bold tracking-tight text-ari-marino">{urlTaxonomyTitle || "Todos los tesoros"}</h1>
<p className="mt-3 text-slate-500">
Filtra por categoría, marca o edad sin recargar la página.
</p>
@@ -381,6 +435,33 @@ export function CatalogClient({
onChange={setCategory}
counts={categoryCounts}
/>
{franchises.length > 1 && (
<Filter
title="Franquicia"
values={franchises}
selected={taxonomy.franquicia || "Todas"}
onChange={(value) => setTaxonomy({ franquicia: value === "Todas" ? undefined : value })}
counts={franchiseCounts}
/>
)}
{taxonomy.saga && (
<div>
<p className="mb-3 text-sm font-black">Saga</p>
<button type="button" onClick={() => setTaxonomy({ ...taxonomy, saga: undefined, personaje: undefined })} className="inline-flex items-center gap-1.5 rounded-md bg-ari-marino px-3 py-2 text-xs font-bold text-white">
{taxonomy.saga}
<X className="size-3" aria-label="Quitar saga" />
</button>
</div>
)}
{characters.length > 1 && (
<Filter
title="Personaje"
values={characters}
selected={taxonomy.personaje || "Todos"}
onChange={(value) => setTaxonomy({ ...taxonomy, personaje: value === "Todos" ? undefined : value })}
counts={characterCounts}
/>
)}
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} />
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />
@@ -3,12 +3,13 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft, Landmark, MapPin, MessageCircle, Truck } from "lucide-react";
import { type FormEvent, useState } from "react";
import { type FormEvent, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
import { formatMoney } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store";
import { useLocationStore } from "@/store/location-store";
const SELECT_CLASS =
"h-12 w-full rounded-full border border-slate-200 bg-white px-5 text-sm text-slate-950 outline-none transition focus:border-cyan-500 focus:ring-4 focus:ring-cyan-300/30 disabled:opacity-50";
@@ -72,12 +73,24 @@ export function CheckoutClient() {
const [docType, setDocType] = useState<"CC" | "CE" | "NIT" | "TI" | "PP">("CC");
const [docNumber, setDocNumber] = useState("");
// Ciudad elegida en el header ("Enviar a ... · Cali"): precarga el
// departamento/ciudad solo si el cliente todavia no eligio nada aca.
const savedDepartmentId = useLocationStore((state) => state.departmentId);
const savedCityId = useLocationStore((state) => state.cityId);
const locationHydrated = useLocationStore((state) => state.hasHydrated);
const saveLocation = useLocationStore((state) => state.setLocation);
useEffect(() => {
if (!locationHydrated || !savedDepartmentId || !savedCityId) return;
setForm((current) => (current.departmentId ? current : { ...current, departmentId: savedDepartmentId, cityId: savedCityId }));
}, [locationHydrated, savedDepartmentId, savedCityId]);
const itemsSubtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0);
const currency = items[0]?.product.currency;
async function submitAddress(event: FormEvent) {
event.preventDefault();
if (!cartId || !selectedCiudad || !selectedDepartamento) return;
saveLocation(selectedDepartamento.id, selectedCiudad.id);
setLoading(true);
setError(null);
try {
@@ -0,0 +1,100 @@
"use client";
import { MapPin } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { ciudades, ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
import { useLocationStore } from "@/store/location-store";
const selectClass =
"h-11 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-950 outline-none focus:border-ari-coral focus:ring-4 focus:ring-ari-coral/15";
// "Enviar a <nombre> · <ciudad>" del header (inspirado en el bloque de
// ubicacion de Amazon). La ciudad elegida se reusa en el checkout.
export function LocationPicker({ firstName }: { firstName: string | null }) {
const departmentId = useLocationStore((state) => state.departmentId);
const cityId = useLocationStore((state) => state.cityId);
const hydrated = useLocationStore((state) => state.hasHydrated);
const setLocation = useLocationStore((state) => state.setLocation);
const [open, setOpen] = useState(false);
const [draftDepartment, setDraftDepartment] = useState<number | null>(null);
const [draftCity, setDraftCity] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const city = hydrated && cityId ? ciudades.find((item) => item.id === cityId)?.name : undefined;
useEffect(() => {
if (!open) return;
setDraftDepartment(departmentId);
setDraftCity(cityId);
function handlePointerDown(event: PointerEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open, departmentId, cityId]);
const draftCities = draftDepartment ? ciudadesPorDepartamento(draftDepartment) : [];
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-label={city ? `Enviar a ${city}. Cambiar ciudad` : "Elegir ciudad de envío"}
className="flex items-center gap-1.5 rounded-xl px-2 py-1.5 text-left transition hover:bg-slate-100"
>
<MapPin className="size-5 shrink-0 text-ari-coral" />
<span className="leading-tight">
<span className="block text-xs text-slate-500">Enviar a {firstName || "Colombia"}</span>
<span className="block max-w-36 truncate text-sm font-black text-ari-marino">{city ?? "Elige tu ciudad"}</span>
</span>
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-5 shadow-2xl">
<p className="font-black text-ari-marino">¿A dónde enviamos?</p>
<p className="mt-1 text-xs text-slate-500">Hacemos envíos a toda Colombia. Tu ciudad queda lista en el checkout.</p>
<div className="mt-4 grid gap-3">
<select
aria-label="Departamento"
className={selectClass}
value={draftDepartment ?? ""}
onChange={(event) => { setDraftDepartment(Number(event.target.value) || null); setDraftCity(null); }}
>
<option value="">Departamento</option>
{departamentos.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<select
aria-label="Ciudad"
className={selectClass}
value={draftCity ?? ""}
disabled={!draftDepartment}
onChange={(event) => setDraftCity(Number(event.target.value) || null)}
>
<option value="">Ciudad o municipio</option>
{draftCities.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<Button
variant="primary"
disabled={!draftDepartment || !draftCity}
onClick={() => {
if (draftDepartment && draftCity) setLocation(draftDepartment, draftCity);
setOpen(false);
}}
>
Guardar ciudad
</Button>
</div>
</div>
)}
</div>
);
}
+356 -34
View File
@@ -2,13 +2,15 @@
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { ChevronDown, Heart, LayoutGrid, Menu, ShoppingCart, UserRound } from "lucide-react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, Heart, LayoutGrid, LifeBuoy, Menu, ShoppingCart, Sparkles, UserRound } from "lucide-react";
import { Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { AccountDialog } from "@/components/account/account-dialog";
import { LocationPicker } from "@/components/layout/location-picker";
import { PredictiveSearch } from "@/components/search/predictive-search";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import type { Category, Product } from "@/lib/headless/types";
import { buildFranchiseTree, taxonomyHref, type FranchiseNode, type TaxonomyKind } from "@/lib/taxonomy";
import { useCartStore } from "@/store/cart-store";
import { useCustomerStore } from "@/store/customer-store";
import { useWishlistStore } from "@/store/wishlist-store";
@@ -42,14 +44,34 @@ export function Navbar({ products, categories }: { products: Product[]; categori
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
// Derivado de los productos reales (no de un endpoint de colecciones):
// solo aparecen colecciones que de verdad tienen productos hoy.
// Franquicia > saga > personaje desde los tags (lib/taxonomy.ts). Si el
// provider no trae tags (mock/shopify), "Personajes" cae a la lista plana
// de colecciones de siempre.
const franchises = useMemo(() => buildFranchiseTree(products), [products]);
const collections = useMemo(
() => [...new Set(products.map((product) => product.collection).filter(Boolean))],
[products],
);
// Header compacto al hacer scroll (solo desktop, lg+): oculta la franja
// promocional y achica logo/padding. Histeresis (compacta pasado 160px,
// se expande recien por debajo de 40px) porque achicar un header sticky
// cambia la altura del documento -- con un solo umbral el header
// parpadea entre los dos estados justo en el borde.
const [compact, setCompact] = useState(false);
useEffect(() => {
const onScroll = () => {
const y = window.scrollY;
setCompact((current) => (current ? y > 40 : y > 160));
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
<div className="bg-ari-marino px-4 py-2 text-center text-xs font-black text-white sm:text-sm">
<header data-compact={compact} className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
<div className={`bg-ari-marino px-4 py-2 text-center text-xs font-black text-white sm:text-sm ${compact ? "lg:hidden" : ""}`}>
Paga tus compras a cuotas · Envíos nacionales · <span className="underline decoration-ari-coral-claro decoration-2 underline-offset-2">Compra segura con ARI</span>
</div>
@@ -61,16 +83,19 @@ export function Navbar({ products, categories }: { products: Product[]; categori
angosto/recortado). En desktop (lg+) se mantiene la fila unica
original -- ahi sobra espacio y partirla en dos solo alargaria
el header sin necesidad. */}
<div className="mx-auto max-w-7xl px-4 py-3 lg:py-4">
<div className="flex items-center justify-between gap-4 lg:grid lg:grid-cols-[auto_1fr_auto] lg:gap-8">
<div className={`mx-auto max-w-7xl px-4 py-3 transition-[padding] ${compact ? "lg:py-2" : "lg:py-4"}`}>
<div className="flex items-center justify-between gap-4 lg:grid lg:grid-cols-[auto_auto_1fr_auto] lg:gap-5">
{/* Isotipo circular con icono + wordmark integrados (como en la
version previa a ADR 0008), pedido explicito del usuario en
vez del lockup rectangular. logo-ari.svg (rectangular) se
mantiene para el footer. */}
<Link href="/" aria-label="ARI shopping" className="shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}
<img src="/images/brand/logo-ari-circular.svg" alt="ARI shopping" className="size-16 lg:size-20" width={200} height={200} />
<img src="/images/brand/logo-ari-circular.svg" alt="ARI shopping" className={`size-16 transition-[width,height] ${compact ? "lg:size-12" : "lg:size-20"}`} width={200} height={200} />
</Link>
<div className="hidden lg:block">
<LocationPicker firstName={isLoggedIn ? customer?.firstName ?? null : null} />
</div>
<div className="hidden lg:block">
<PredictiveSearch />
</div>
@@ -84,6 +109,17 @@ export function Navbar({ products, categories }: { products: Product[]; categori
<UserRound />
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
</Button>
{/* Texto al lado de los iconos solo en desktop (estilo "Hola
Cristian / Cuenta y listas" de Amazon): en mobile no hay
lugar y los iconos solos ya se entienden. */}
<button type="button" onClick={() => setAccountOpen(true)} className="hidden rounded-xl px-2 py-1 text-left leading-tight transition hover:bg-slate-100 xl:block">
<span className="block text-xs text-slate-500">{isLoggedIn ? `Hola, ${customer?.firstName || "cliente"}` : "Hola, inicia sesión"}</span>
<span className="block text-sm font-black text-ari-marino">Mi cuenta</span>
</button>
<Link href="/cambios" prefetch={false} className="mr-1 hidden rounded-xl px-2 py-1 leading-tight transition hover:bg-slate-100 xl:block">
<span className="block text-xs text-slate-500">Cambios y</span>
<span className="block text-sm font-black text-ari-marino">devoluciones</span>
</Link>
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={openWishlist}>
<Heart />
{wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-ari-marino text-[10px] font-black text-white">{wishlistCount}</span>}
@@ -100,8 +136,8 @@ export function Navbar({ products, categories }: { products: Product[]; categori
</div>
</div>
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} onOpenMenu={() => setMenuOpen(true)} />}>
<DesktopCategoryNavigation tree={tree} onOpenMenu={() => setMenuOpen(true)} />
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} collections={collections} franchises={franchises} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}>
<DesktopCategoryNavigation tree={tree} collections={collections} franchises={franchises} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />
</Suspense>
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
@@ -117,7 +153,28 @@ export function Navbar({ products, categories }: { products: Product[]; categori
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
</Suspense>
</div>
{collections.length > 0 && (
{franchises.length > 0 && (
<>
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Personajes</p>
<div className="grid gap-4">
{franchises.map((franchise) => (
<div key={franchise.name}>
<Link href={taxonomyHref("franquicia", franchise.name)} prefetch={false} onClick={() => setMenuOpen(false)} className="font-black text-ari-marino hover:text-ari-coral">
{franchise.name} <span className="font-bold text-slate-400">({franchise.count})</span>
</Link>
<div className="mt-2 flex flex-wrap gap-2">
{franchise.sagas.map((saga) => (
<Link key={saga.name} href={taxonomyHref("saga", saga.name)} prefetch={false} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-3 py-1.5 text-sm font-bold hover:bg-ari-coral/10 hover:text-ari-coral">
{saga.name}
</Link>
))}
</div>
</div>
))}
</div>
</>
)}
{franchises.length === 0 && collections.length > 0 && (
<>
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Colecciones</p>
<div className="flex flex-wrap gap-2">
@@ -142,16 +199,32 @@ function useCategoryNavigationState() {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentCategory = searchParams.get("category") ?? "";
const currentCollection = searchParams.get("collection") ?? "";
const currentTaxonomy = { franquicia: searchParams.get("franquicia") ?? "", saga: searchParams.get("saga") ?? "", personaje: searchParams.get("personaje") ?? "" };
const anyTaxonomy = Boolean(currentTaxonomy.franquicia || currentTaxonomy.saga || currentTaxonomy.personaje);
const isCatalog = pathname === "/catalog";
return {
isAllActive: isCatalog && !currentCategory,
isAllActive: isCatalog && !currentCategory && !currentCollection && !anyTaxonomy,
isActive: (name: string) => isCatalog && currentCategory === name,
isCollectionActive: (name: string) => isCatalog && currentCollection === name,
isTaxonomyActive: (kind: TaxonomyKind, name: string) => isCatalog && currentTaxonomy[kind] === name,
anyTaxonomyActive: isCatalog && anyTaxonomy,
};
}
function pillClass(active: boolean): string {
return `shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${
type NavigationState = ReturnType<typeof useCategoryNavigationState>;
const INACTIVE_NAVIGATION: NavigationState = {
isAllActive: false,
isActive: () => false,
isCollectionActive: () => false,
isTaxonomyActive: () => false,
anyTaxonomyActive: false,
};
function pillClass(active: boolean, dense = false): string {
return `shrink-0 rounded-full ${dense ? "px-2.5" : "px-4"} py-2 text-sm font-extrabold transition ${
active
? "bg-white text-ari-marino shadow-lg shadow-black/10"
: "text-white/90 hover:bg-white/10 hover:text-white"
@@ -166,26 +239,26 @@ function mobileLinkClass(active: boolean): string {
}`;
}
function DesktopCategoryNavigation({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
const { isActive, isAllActive } = useCategoryNavigationState();
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={isActive} isAllActive={isAllActive} />;
type NavBarProps = { tree: CategoryNode[]; collections: string[]; franchises: FranchiseNode[]; onOpenMenu: () => void; onOpenAccount: () => void };
function DesktopCategoryNavigation(props: NavBarProps) {
return <CategoryNavBar {...props} state={useCategoryNavigationState()} />;
}
function DesktopCategoryNavigationFallback({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={() => false} isAllActive={false} />;
function DesktopCategoryNavigationFallback(props: NavBarProps) {
return <CategoryNavBar {...props} state={INACTIVE_NAVIGATION} />;
}
function CategoryNavBar({
tree,
onOpenMenu,
isActive,
isAllActive,
}: {
tree: CategoryNode[];
onOpenMenu: () => void;
isActive: (name: string) => boolean;
isAllActive: boolean;
}) {
// Desktop: con 1280px+ las 6 categorias raiz entran directo en la barra
// (cada una con su desplegable de subcategorias) -- antes solo se veian
// "Departamentos" y "Todo" y el resto de la barra quedaba vacia. Entre
// 1024 y 1280px no entran todas, asi que ahi se mantiene el mega menu de
// "Departamentos". "Personajes" (colecciones) va a la derecha desde lg.
// La hamburguesa solo en mobile: en desktop repetia lo mismo que la barra.
function CategoryNavBar({ tree, collections, franchises, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) {
const { isActive, isAllActive, isCollectionActive, isTaxonomyActive, anyTaxonomyActive } = state;
const anyCollectionActive = collections.some(isCollectionActive) || anyTaxonomyActive;
return (
<nav className="bg-ari-marino text-white">
<div className="relative mx-auto flex max-w-7xl items-center gap-1 px-4 py-2">
@@ -194,24 +267,265 @@ function CategoryNavBar({
size="icon"
onClick={onOpenMenu}
aria-label="Abrir menú principal"
className="mr-2 shrink-0 text-white hover:bg-white/10"
className="mr-2 shrink-0 text-white hover:bg-white/10 lg:hidden"
>
<Menu />
</Button>
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
<div className="shrink-0 xl:hidden">
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
</div>
<Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
Todo
</Link>
<div className="hidden items-center gap-0.5 xl:flex">
{tree.map((root) => {
const active = isActive(root.name) || root.children.some((child) => isActive(child.name));
const href = `/catalog?category=${encodeURIComponent(root.name)}`;
if (root.children.length === 0) {
return (
<Link key={root.id} href={href} prefetch={false} aria-current={active ? "page" : undefined} className={pillClass(active, true)}>
{root.name}
</Link>
);
}
return (
<NavDropdown key={root.id} label={root.name} active={active}>
{(close) => (
<ul className="grid min-w-56 gap-1">
{root.children.map((child) => (
<li key={child.id}>
<Link
href={`/catalog?category=${encodeURIComponent(child.name)}`}
prefetch={false}
aria-current={isActive(child.name) ? "page" : undefined}
onClick={close}
className={`block rounded-lg px-3 py-2 text-sm transition ${isActive(child.name) ? "bg-white/10 font-bold text-ari-coral-claro" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
>
{child.name}
</Link>
</li>
))}
<li className="mt-1 border-t border-white/10 pt-2">
<Link href={href} prefetch={false} onClick={close} className="block rounded-lg px-3 py-2 text-sm font-black text-ari-coral-claro hover:bg-white/10">
Ver todo en {root.name}
</Link>
</li>
</ul>
)}
</NavDropdown>
);
})}
</div>
<div className="ml-auto hidden shrink-0 items-center gap-0.5 border-l border-white/20 pl-2 lg:flex xl:ml-2">
{(franchises.length > 0 || collections.length > 0) && (
<NavDropdown label="Personajes" active={anyCollectionActive} align="right" icon={<Sparkles className="size-4" />}>
{(close) => franchises.length > 0 ? (
<FranchisePanel franchises={franchises} isTaxonomyActive={isTaxonomyActive} close={close} />
) : (
<div className="grid w-[28rem] grid-cols-2 gap-1.5">
{collections.map((collection) => (
<Link
key={collection}
href={`/catalog?collection=${encodeURIComponent(collection)}`}
prefetch={false}
aria-current={isCollectionActive(collection) ? "page" : undefined}
onClick={close}
className={`rounded-xl px-3 py-2.5 text-sm font-bold transition ${isCollectionActive(collection) ? "bg-white text-ari-marino" : "bg-white/5 text-white hover:bg-white/15"}`}
>
{collection}
</Link>
))}
</div>
)}
</NavDropdown>
)}
{/* Equivalente al "Podemos ayudarte" de Amazon, con las paginas
de ayuda que la tienda ya tiene (antes solo en el footer). */}
<NavDropdown label="Ayuda" labelClassName="sr-only" ariaLabel="Ayuda" active={false} align="right" icon={<LifeBuoy className="size-4" />}>
{(close) => (
<ul className="grid min-w-60 gap-1">
<li><button type="button" onClick={() => { close(); onOpenAccount(); }} className={helpLinkClass}>Tu cuenta</button></li>
<li><Link href="/cuenta/pedidos" prefetch={false} onClick={close} className={helpLinkClass}>Tus pedidos y reseñas</Link></li>
<li><Link href="/envios" prefetch={false} onClick={close} className={helpLinkClass}>Envíos y tarifas</Link></li>
<li><Link href="/cambios" prefetch={false} onClick={close} className={helpLinkClass}>Cambios y devoluciones</Link></li>
<li><Link href="/preguntas-frecuentes" prefetch={false} onClick={close} className={helpLinkClass}>Preguntas frecuentes</Link></li>
<li><Link href="/contacto" prefetch={false} onClick={close} className={helpLinkClass}>Contacto</Link></li>
<li className="mt-1 border-t border-white/10 pt-2">
<a href="https://wa.me/573016138155" target="_blank" rel="noopener noreferrer" onClick={close} className={`${helpLinkClass} font-black text-ari-coral-claro`}>Escríbenos por WhatsApp</a>
</li>
</ul>
)}
</NavDropdown>
</div>
</div>
</nav>
);
}
// Con mouse, el mismo gesto dispara mouseenter (abre) y click: si el click
// alternara, cerraria el menu que se acaba de abrir por hover y para el
// usuario "no abre" (reportado por frontend-critic, reproducido 4/4 con
// Playwright; el bug ya existia en DepartmentsMegaMenu en prod). Un click
// dentro de los 600ms posteriores a abrir por hover lo deja abierto; en
// touch (sin hover) el click sigue alternando normal.
// El hover usa onPointerEnter/Leave filtrando pointerType === "mouse": en
// touch, Chromium emite un mouseenter+mouseleave sinteticos por cada tap, y
// ese mouseleave fantasma agendaba el cierre de 200ms -- el menu se cerraba
// solo en tablets (iPad horizontal cae en el rango del mega menu).
// Reportado por frontend-critic con log de eventos del navegador.
function keepOpenAfterHover(open: boolean, hoverOpenedAt: number): boolean {
if (open && Date.now() - hoverOpenedAt < 600) return true;
return !open;
}
// Mega panel de "Personajes" (inspirado en el menu "Theme" de Entertainment
// Earth): una columna por franquicia con su total, sus sagas y los
// personajes de cada saga -- "Disney" trae todo Disney (Pixar incluido),
// "Spider-Man" solo lo de Spider-Man.
function FranchisePanel({
franchises,
isTaxonomyActive,
close,
}: {
franchises: FranchiseNode[];
isTaxonomyActive: (kind: TaxonomyKind, name: string) => boolean;
close: () => void;
}) {
const link = (kind: TaxonomyKind, name: string, base: string) => (
<Link
href={taxonomyHref(kind, name)}
prefetch={false}
aria-current={isTaxonomyActive(kind, name) ? "page" : undefined}
onClick={close}
className={`${base} ${isTaxonomyActive(kind, name) ? "text-ari-coral-claro" : ""}`}
>
{name}
</Link>
);
return (
<div className="grid w-max max-w-[min(92vw,56rem)] gap-x-8 gap-y-5 p-2" style={{ gridTemplateColumns: `repeat(${Math.min(franchises.length, 4)}, minmax(10rem, 1fr))` }}>
{franchises.map((franchise) => (
<div key={franchise.name}>
<p className="flex items-baseline gap-1.5 border-b border-white/15 pb-2">
{link("franquicia", franchise.name, "text-sm font-black uppercase tracking-wide text-white hover:text-ari-coral-claro")}
<span className="text-xs font-bold text-white/50">{franchise.count}</span>
</p>
<ul className="mt-2.5 grid gap-2.5">
{franchise.sagas.map((saga) => (
<li key={saga.name}>
{link("saga", saga.name, "text-sm font-bold text-white hover:text-ari-coral-claro")}
{saga.characters.length > 1 || (saga.characters[0] && saga.characters[0].name !== saga.name) ? (
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-0.5">
{saga.characters.map((character) => (
<span key={character.name}>{link("personaje", character.name, "text-xs text-white/80 hover:text-white")}</span>
))}
</div>
) : null}
</li>
))}
</ul>
</div>
))}
</div>
);
}
const helpLinkClass = "block w-full rounded-lg px-3 py-2 text-left text-sm text-white/80 transition hover:bg-white/10 hover:text-white";
// Desplegable con la misma "intencion de hover" que DepartmentsMegaMenu
// (cierre con 200ms de gracia para movimientos en diagonal, click afuera y
// Escape cierran), pero anclado al propio boton en vez de a todo el ancho.
function NavDropdown({
label,
labelClassName,
ariaLabel,
active,
align = "left",
icon,
children,
}: {
label: string;
labelClassName?: string;
ariaLabel?: string;
active: boolean;
align?: "left" | "right";
icon?: ReactNode;
children: (close: () => void) => ReactNode;
}) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const hoverOpenedAt = useRef(0);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const scheduleClose = () => {
cancelClose();
closeTimer.current = setTimeout(() => setOpen(false), 200);
};
useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
// Devolver el foco al boton que abrio el menu: si no, queda en
// <body> y quien navega con teclado pierde su posicion (reportado
// por frontend-critic).
triggerRef.current?.focus();
}
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
useEffect(() => () => cancelClose(), []);
return (
<div ref={containerRef} className="relative shrink-0" onPointerEnter={(event) => { if (event.pointerType !== "mouse") return; cancelClose(); if (!open) hoverOpenedAt.current = Date.now(); setOpen(true); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
<button
ref={triggerRef}
type="button"
aria-label={ariaLabel}
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
aria-expanded={open}
aria-haspopup="true"
className={`flex items-center gap-1.5 ${pillClass(active || open, true)}`}
>
{icon}
<span className={labelClassName}>{label}</span>
<ChevronDown className={`size-3.5 opacity-70 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className={`absolute top-full z-50 pt-1 ${align === "right" ? "right-0" : "left-0"}`}>
<div className="rounded-2xl border border-white/10 bg-ari-marino p-3 shadow-2xl">{children(() => setOpen(false))}</div>
</div>
)}
</div>
);
}
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const hoverOpenedAt = useRef(0);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => {
@@ -237,7 +551,13 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
if (event.key === "Escape") {
setOpen(false);
// Devolver el foco al boton que abrio el menu: si no, queda en
// <body> y quien navega con teclado pierde su posicion (reportado
// por frontend-critic).
triggerRef.current?.focus();
}
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
@@ -251,14 +571,16 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
const openNow = () => {
cancelClose();
if (!open) hoverOpenedAt.current = Date.now();
setOpen(true);
};
return (
<div ref={containerRef} className="shrink-0" onMouseEnter={openNow} onMouseLeave={scheduleClose}>
<div ref={containerRef} className="shrink-0" onPointerEnter={(event) => { if (event.pointerType === "mouse") openNow(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((value) => !value)}
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
aria-expanded={open}
aria-haspopup="true"
aria-label="Departamentos"
@@ -0,0 +1,22 @@
"use client";
import { useEffect } from "react";
import { StarRating } from "@/components/product/star-rating";
import { useReviewSummaryStore } from "@/store/review-summary-store";
// Promedio + cantidad de reseñas aprobadas. No renderiza nada si el
// producto aun no tiene reseñas (ni un link vacio, en la ficha).
export function CardRating({ productId, href }: { productId: string; href?: string }) {
const load = useReviewSummaryStore((state) => state.load);
const entry = useReviewSummaryStore((state) => state.summary[productId]);
useEffect(() => load(), [load]);
if (!entry || entry.count === 0) return null;
const content = (
<>
<StarRating value={entry.average} />
{entry.average.toLocaleString("es-CO", { minimumFractionDigits: 1 })} ({entry.count} {entry.count === 1 ? "reseña" : "reseñas"})
</>
);
const className = "flex items-center gap-1.5 text-xs font-bold text-slate-500";
return href ? <a href={href} className={`${className} mt-3 hover:text-ari-coral`}>{content}</a> : <span className={className}>{content}</span>;
}
@@ -0,0 +1,126 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useRef, useState } from "react";
// Galería deslizable de la tarjeta de producto: permite ver todas las
// imágenes desde el listado sin entrar al producto. En móvil se desliza
// con el dedo (scroll-snap nativo); en escritorio aparecen flechas al
// pasar el mouse. Cada imagen sigue siendo un link a la ficha.
export function ProductCardGallery({
images,
name,
href,
priority = false,
}: {
images: string[];
name: string;
href: string;
priority?: boolean;
}) {
const scroller = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(0);
const total = images.length;
const goTo = (target: number) => {
const node = scroller.current;
if (!node) return;
const next = Math.max(0, Math.min(total - 1, target));
node.scrollTo({ left: next * node.clientWidth, behavior: "smooth" });
};
const onScroll = () => {
const node = scroller.current;
if (!node || node.clientWidth === 0) return;
setIndex(Math.round(node.scrollLeft / node.clientWidth));
};
return (
<>
<div
ref={scroller}
onScroll={total > 1 ? onScroll : undefined}
className="flex h-full snap-x snap-mandatory overflow-x-auto [overscroll-behavior-x:contain] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{images.map((src, slide) => (
<Link
key={src}
href={href}
prefetch={false}
className="relative block h-full w-full shrink-0 snap-center snap-always"
aria-label={total > 1 ? `${name}, imagen ${slide + 1} de ${total}` : name}
tabIndex={slide === 0 ? 0 : -1}
>
{/* object-contain (no object-cover): las fotos secundarias del
catálogo no son cuadradas (verticales tipo 933x1500 u
horizontales) y cover les cortaba cabeza, pies o bordes
-- reportado en vivo con Tristeza y Buzz. Mismo criterio que
product-gallery.tsx; el letterbox queda del bg-white de la
tarjeta. */}
{/* unoptimized desde la 2da imagen: pasar todas por el
optimizador de Next multiplica el trabajo de sharp dentro
del pod (~58 imagenes en el catalogo vs. 18 antes) -- una
carga en rafaga de todas las galerias dejo un pod de prod
OOMKilled (limite 768Mi) el 2026-09-27. Los originales de
media.cruzcloud.net pesan ~165 KB promedio y son lazy: solo
se bajan al deslizar. La 1ra sigue optimizada como antes. */}
<Image
src={src}
alt={slide === 0 ? name : `${name} (imagen ${slide + 1})`}
fill
unoptimized={slide > 0}
priority={priority && slide === 0}
sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px"
className="object-contain transition duration-500 group-hover:scale-[1.035]"
/>
</Link>
))}
</div>
{total > 1 && (
<>
<button
type="button"
aria-label="Imagen anterior"
disabled={index === 0}
onClick={() => goTo(index - 1)}
className="absolute left-2 top-1/2 hidden size-9 -translate-y-1/2 place-items-center rounded-full bg-white/95 text-ari-marino opacity-0 shadow-lg transition group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:!opacity-0 pointer-fine:grid"
>
<ChevronLeft className="size-5" />
</button>
<button
type="button"
aria-label="Imagen siguiente"
disabled={index === total - 1}
onClick={() => goTo(index + 1)}
className="absolute right-2 top-1/2 hidden size-9 -translate-y-1/2 place-items-center rounded-full bg-white/95 text-ari-marino opacity-0 shadow-lg transition group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:!opacity-0 pointer-fine:grid"
>
<ChevronRight className="size-5" />
</button>
<div className="absolute inset-x-0 bottom-2 flex justify-center">
<div className="flex items-center rounded-full bg-white/85 px-1 shadow-sm backdrop-blur">
{images.map((src, dot) => (
<button
key={src}
type="button"
aria-label={`Ver imagen ${dot + 1} de ${total}`}
aria-current={dot === index}
onClick={() => goTo(dot)}
className="grid h-5 place-items-center px-[3px]"
>
<span
className={`block h-1.5 rounded-full transition-all ${
dot === index ? "w-4 bg-ari-coral" : "w-1.5 bg-ari-marino/35"
}`}
/>
</button>
))}
</div>
</div>
</>
)}
</>
);
}
@@ -1,9 +1,10 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react";
import { useState } from "react";
import { CardRating } from "@/components/product/card-rating";
import { ProductCardGallery } from "@/components/product/product-card-gallery";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
@@ -22,6 +23,9 @@ export function ProductCard({ product, priority = false }: { product: Product; p
? Math.round((1 - product.price / product.compareAtPrice) * 100)
: null;
const galleryImages = Array.from(new Set(product.images.filter(Boolean)));
if (galleryImages.length === 0) galleryImages.push(product.thumbnail);
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id);
const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0];
const outOfStock = selectedVariant?.available === false;
@@ -37,7 +41,7 @@ export function ProductCard({ product, priority = false }: { product: Product; p
return (
<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">
<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>
<ProductCardGallery images={galleryImages} name={product.name} href={`/product/${product.slug}`} priority={priority} />
<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>
@@ -45,6 +49,7 @@ export function ProductCard({ product, priority = false }: { product: Product; p
<div className="flex flex-1 flex-col space-y-3 p-5">
<div className="flex items-center justify-between gap-2 text-xs font-bold text-slate-500"><span>{product.category}</span><span className="rounded-md bg-ari-coral-claro/10 px-2 py-1 text-ari-marino">{product.collection}</span></div>
<Link href={`/product/${product.slug}`} prefetch={false}><h3 className="line-clamp-2 min-h-12 text-lg font-black leading-6 text-ari-marino group-hover:text-ari-coral">{product.name}</h3></Link>
<CardRating productId={product.id} />
<p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p>
{product.variants.length > 1 && (
<div className="flex flex-wrap gap-1.5">
@@ -179,7 +179,18 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
className="pointer-events-none absolute inset-0 bg-slate-50 transition-opacity duration-150 ease-out"
style={{
opacity: hoverZoom && !hoverZoom.showPanel ? 1 : 0,
backgroundImage: `url(${selected})`,
// Comillas dentro de url() a proposito: sin ellas, un
// espacio o parentesis literal en el nombre de archivo
// (ej. "descarga (4)-xxx.png", el patron por defecto de
// Chrome al descargar una imagen sin renombrarla -- asi
// quedan las subidas manuales, a diferencia de las
// importadas por CSV) rompe la sintaxis de url() sin
// comillas -- el navegador descarta el valor completo en
// silencio (backgroundImage queda "none"). Confirmado en
// vivo 2026-09-20: reportado por el usuario como "el zoom
// interactivo no se visualiza" solo en fotos subidas a
// mano por el admin, nunca en las de CSV.
backgroundImage: `url("${selected}")`,
backgroundRepeat: "no-repeat",
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
backgroundPosition: hoverZoom ? `${hoverZoom.bgX}% ${hoverZoom.bgY}%` : "center",
@@ -217,7 +228,9 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
top: hoverZoom.panelTop,
width: hoverZoom.panelSize,
height: hoverZoom.panelSize,
backgroundImage: `url(${selected})`,
// Ver el comentario en el overlay in-place de arriba -- mismo
// fix, mismo motivo (comillas necesarias dentro de url()).
backgroundImage: `url("${selected}")`,
backgroundRepeat: "no-repeat",
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
backgroundPosition: `${hoverZoom.bgX}% ${hoverZoom.bgY}%`,
@@ -0,0 +1,64 @@
"use client";
import Link from "next/link";
import { BadgeCheck, MessageSquareText } from "lucide-react";
import { useEffect, useState } from "react";
import { StarRating } from "@/components/product/star-rating";
type Review = { id: string; author_name: string; rating: number; title: string | null; content: string; created_at: string; verified_purchase: boolean };
// Reseñas de la ficha: solo las aprobadas por el dueño (el backend no
// devuelve las pendientes), siempre de compradores verificados.
export function ProductReviews({ productId }: { productId: string }) {
const [data, setData] = useState<{ reviews: Review[]; average: number; count: number } | null>(null);
useEffect(() => {
fetch(`/api/reviews?product_id=${encodeURIComponent(productId)}`)
.then((response) => response.json())
.then(setData)
.catch(() => setData({ reviews: [], average: 0, count: 0 }));
}, [productId]);
return (
<section id="resenas" aria-labelledby="resenas-titulo" className="mt-10 scroll-mt-40">
<h2 id="resenas-titulo" className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-slate-500">
<MessageSquareText className="size-4" />
Reseñas de clientes
</h2>
{!data ? (
<p className="mt-4 text-sm text-slate-400">Cargando reseñas…</p>
) : data.count === 0 ? (
<div className="mt-4 rounded-2xl border border-dashed border-slate-300 p-5 text-sm text-slate-600">
Aún no hay reseñas de este producto. ¿Ya lo compraste?{" "}
<Link href="/cuenta/pedidos" className="font-bold text-ari-marino underline decoration-ari-coral-claro underline-offset-2">Califícalo desde Mis pedidos</Link>.
</div>
) : (
<>
<div className="mt-4 flex items-center gap-3">
<StarRating value={data.average} size="lg" />
<span className="text-2xl font-black text-ari-marino">{data.average.toLocaleString("es-CO", { minimumFractionDigits: 1 })}</span>
<span className="text-sm text-slate-500">de 5 · {data.count} {data.count === 1 ? "reseña" : "reseñas"}</span>
</div>
<ul className="mt-6 grid gap-4">
{data.reviews.map((review) => (
<li key={review.id} className="rounded-2xl border border-slate-200 p-5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<StarRating value={review.rating} />
{review.title && <strong className="text-slate-900">{review.title}</strong>}
</div>
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-slate-700">{review.content}</p>
<p className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
<span className="font-bold text-slate-700">{review.author_name}</span>
<span>{new Date(review.created_at).toLocaleDateString("es-CO", { year: "numeric", month: "long", day: "numeric" })}</span>
{review.verified_purchase && (
<span className="inline-flex items-center gap-1 font-bold text-emerald-700"><BadgeCheck className="size-3.5" />Compra verificada</span>
)}
</p>
</li>
))}
</ul>
</>
)}
</section>
);
}
@@ -0,0 +1,18 @@
import { Star } from "lucide-react";
// Estrellas de solo lectura. El relleno parcial (ej. 4.5) se dibuja
// recortando una capa de estrellas llenas encima de las vacias. amber-600
// (no 400): contraste ~3.2:1 sobre blanco, minimo WCAG 1.4.11 para un
// grafico informativo (amber-400 daba 1.7:1, reportado por frontend-critic).
export function StarRating({ value, size = "sm", className = "" }: { value: number; size?: "sm" | "md" | "lg"; className?: string }) {
const px = size === "lg" ? "size-6" : size === "md" ? "size-5" : "size-4";
const pct = Math.max(0, Math.min(100, (value / 5) * 100));
return (
<span className={`relative inline-flex ${className}`} role="img" aria-label={`${value.toLocaleString("es-CO")} de 5 estrellas`}>
<span className="flex text-slate-300">{[0, 1, 2, 3, 4].map((i) => <Star key={i} className={`${px} fill-current`} />)}</span>
<span className="absolute inset-0 flex overflow-hidden text-amber-600" style={{ width: `${pct}%` }}>
{[0, 1, 2, 3, 4].map((i) => <Star key={i} className={`${px} shrink-0 fill-current`} />)}
</span>
</span>
);
}
@@ -25,14 +25,14 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.273
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.286
env:
# La actualiza el mismo paso del CD (.gitea/workflows/build.yaml)
# que bumpea el tag de la imagen -- el footer la lee
# (process.env.APP_VERSION) para mostrar la versión real
# desplegada en vez de un "v4.1" fijo.
- name: APP_VERSION
value: v1.0.273
value: v1.0.286
- name: HEADLESS_PROVIDER
value: medusa
- name: MEDUSA_BACKEND_URL
@@ -0,0 +1,111 @@
// Reseñas de productos + pedidos del cliente (Store API de Medusa, modulo
// propio commerce-backend/src/modules/product-review). Server-side y sin
// cache: las reseñas cambian al aprobarse en el admin y los pedidos son
// estado de sesion.
function backendUrl(): string {
const value = process.env.MEDUSA_BACKEND_URL;
if (!value) throw new Error("MEDUSA_BACKEND_URL no está configurado.");
return value.replace(/\/$/, "");
}
async function medusaFetch<T>(path: string, init?: { method?: string; body?: unknown; token?: string }): Promise<T> {
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 } : {}),
...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}),
},
body: init?.body ? JSON.stringify(init.body) : undefined,
cache: "no-store",
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const message = typeof data.message === "string" ? data.message : `Error ${response.status}`;
throw Object.assign(new Error(message), { status: response.status });
}
return data as T;
}
export interface PublicReview {
id: string;
author_name: string;
rating: number;
title: string | null;
content: string;
created_at: string;
verified_purchase: boolean;
}
export interface ReviewSummary {
average: number;
count: number;
}
export interface MyReview {
id: string;
product_id: string;
order_id: string;
rating: number;
status: "pending" | "approved" | "rejected";
}
export interface CustomerOrder {
id: string;
displayId: number | null;
createdAt: string;
status: string;
total: number | null;
currency: string;
items: { productId: string; title: string; thumbnail: string | null; handle: string | null; quantity: number }[];
}
export function getProductReviews(productId: string) {
return medusaFetch<{ reviews: PublicReview[]; average: number; count: number }>(`/store/reviews?product_id=${encodeURIComponent(productId)}`);
}
export async function getReviewSummary(): Promise<Record<string, ReviewSummary>> {
return (await medusaFetch<{ summary: Record<string, ReviewSummary> }>("/store/reviews/summary")).summary;
}
export async function getMyReviews(token: string): Promise<MyReview[]> {
return (await medusaFetch<{ reviews: MyReview[] }>("/store/reviews/me", { token })).reviews;
}
export function createReview(token: string, body: { product_id: string; order_id: string; rating: number; title?: string; content: string }) {
return medusaFetch<{ review: { id: string; status: string } }>("/store/reviews", { method: "POST", body, token });
}
type MedusaOrder = {
id: string;
display_id?: number | null;
created_at: string;
status: string;
total?: number | null;
currency_code?: string | null;
items?: { product_id?: string | null; product_title?: string | null; title?: string | null; thumbnail?: string | null; product_handle?: string | null; quantity?: number }[];
};
export async function getMyOrders(token: string): Promise<CustomerOrder[]> {
const fields = "id,display_id,created_at,status,total,currency_code,*items";
const data = await medusaFetch<{ orders: MedusaOrder[] }>(`/store/orders?limit=50&order=-created_at&fields=${encodeURIComponent(fields)}`, { token });
return data.orders.map((order) => ({
id: order.id,
displayId: order.display_id ?? null,
createdAt: order.created_at,
status: order.status,
total: typeof order.total === "number" ? order.total : null,
currency: (order.currency_code ?? "cop").toUpperCase(),
items: (order.items ?? [])
.filter((item) => item.product_id)
.map((item) => ({
productId: item.product_id as string,
title: item.product_title || item.title || "Producto",
thumbnail: item.thumbnail ?? null,
handle: item.product_handle ?? null,
quantity: item.quantity ?? 1,
})),
}));
}
+68
View File
@@ -0,0 +1,68 @@
import type { Product } from "@/lib/headless/types";
// Taxonomia Franquicia > Saga > Personaje (ej. Disney > Toy Story > Rex,
// Marvel > Spider-Man > Spider-Man). Vive en los tags de Medusa con
// prefijo ("franquicia:Disney", "saga:Toy Story", "personaje:Rex") porque
// la coleccion de Medusa es una sola por producto y mezclaba niveles
// ("Disney" y "Toy Story" como pares). Los tags son muchos-a-muchos: un
// producto puede tener varios personajes ("Minnie y Mickey").
export type TaxonomyKind = "franquicia" | "saga" | "personaje";
export const TAXONOMY_KINDS: TaxonomyKind[] = ["franquicia", "saga", "personaje"];
export function taxonomyValues(product: Product, kind: TaxonomyKind): string[] {
const prefix = `${kind}:`;
return product.tags.filter((tag) => tag.startsWith(prefix)).map((tag) => tag.slice(prefix.length));
}
export type TaxonomyFilter = Partial<Record<TaxonomyKind, string>>;
export function matchesTaxonomy(product: Product, filter: TaxonomyFilter): boolean {
return TAXONOMY_KINDS.every((kind) => {
const value = filter[kind];
return !value || taxonomyValues(product, kind).includes(value);
});
}
export type SagaNode = { name: string; count: number; characters: { name: string; count: number }[] };
export type FranchiseNode = { name: string; count: number; sagas: SagaNode[] };
function byCountThenName<T extends { name: string; count: number }>(a: T, b: T): number {
return b.count - a.count || a.name.localeCompare(b.name, "es");
}
export function buildFranchiseTree(products: Product[]): FranchiseNode[] {
const franchises = new Map<string, { count: number; sagas: Map<string, { count: number; characters: Map<string, number> }> }>();
for (const product of products) {
const sagas = taxonomyValues(product, "saga");
const characters = taxonomyValues(product, "personaje");
for (const franchise of taxonomyValues(product, "franquicia")) {
const node = franchises.get(franchise) ?? { count: 0, sagas: new Map() };
node.count += 1;
for (const saga of sagas) {
const sagaNode = node.sagas.get(saga) ?? { count: 0, characters: new Map() };
sagaNode.count += 1;
for (const character of characters) sagaNode.characters.set(character, (sagaNode.characters.get(character) ?? 0) + 1);
node.sagas.set(saga, sagaNode);
}
franchises.set(franchise, node);
}
}
return [...franchises.entries()]
.map(([name, node]) => ({
name,
count: node.count,
sagas: [...node.sagas.entries()]
.map(([sagaName, sagaNode]) => ({
name: sagaName,
count: sagaNode.count,
characters: [...sagaNode.characters.entries()].map(([characterName, count]) => ({ name: characterName, count })).sort(byCountThenName),
}))
.sort(byCountThenName),
}))
.sort(byCountThenName);
}
export function taxonomyHref(kind: TaxonomyKind, value: string): string {
return `/catalog?${kind}=${encodeURIComponent(value)}`;
}
@@ -0,0 +1,34 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
// Ciudad de envio elegida desde el header ("Enviar a ... · Cali"), mismo
// patron persistido que wishlist/cart. Se guardan los ids DIVIPOLA (no
// texto libre, ver lib/data/colombia.ts) para que el checkout arranque con
// el mismo departamento/ciudad ya seleccionados.
interface LocationState {
departmentId: number | null;
cityId: number | null;
hasHydrated: boolean;
setHasHydrated: (value: boolean) => void;
setLocation: (departmentId: number, cityId: number) => void;
}
export const useLocationStore = create<LocationState>()(
persist(
(set) => ({
departmentId: null,
cityId: null,
hasHydrated: false,
setHasHydrated: (value) => set({ hasHydrated: value }),
setLocation: (departmentId, cityId) => set({ departmentId, cityId }),
}),
{
name: "ari-shopping-location-v1",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ departmentId: state.departmentId, cityId: state.cityId }),
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
},
),
);
@@ -0,0 +1,26 @@
"use client";
import { create } from "zustand";
// Promedio/cantidad de reseñas aprobadas por producto, para las estrellas
// de las tarjetas. Se pide UNA vez por sesion (/api/reviews/summary) en
// vez de una llamada por tarjeta, y sin depender del cache ISR de las
// paginas (una reseña recien aprobada aparece al recargar).
interface ReviewSummaryState {
summary: Record<string, { average: number; count: number }>;
status: "idle" | "loading" | "ready";
load: () => void;
}
export const useReviewSummaryStore = create<ReviewSummaryState>()((set, get) => ({
summary: {},
status: "idle",
load: () => {
if (get().status !== "idle") return;
set({ status: "loading" });
fetch("/api/reviews/summary")
.then((response) => response.json())
.then((data) => set({ summary: data.summary ?? {}, status: "ready" }))
.catch(() => set({ status: "ready" }));
},
}));