Compare commits

..
Author SHA1 Message Date
devops c243f02ec4 docs(adr): marcar ADR 0006 como aceptada, nota de implementación
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 51s
Build and Push Docs Portal / Construir y publicar Docs Portal (pull_request) Skipped
Aprobada tras el diagnóstico de memoria del host (swap saturado
resuelto, ~500-700MiB liberados al desactivar monitoring). Agrega
nota de implementación con 3 desviaciones reales encontradas al
verificar el código fuente del plugin en el tag v1.4.1 antes de
escribir código: no hace falta subscriber propio (el plugin trae los
suyos), las rutas del plugin no devuelven facetDistribution (se
agrega una ruta Store API propia), y se conecta vía plugins: no
modules:. Ninguna cambia la decisión, solo el cómo.
2026-08-26 14:56:19 -05:00
devops 31782a2838 Merge main into fix/adr0006-motor-busqueda-facetas 2026-08-26 14:55:47 -05:00
devops 9069f165c0 Merge fix/disable-monitoring-stack into main 2026-08-26 14:08:28 -05:00
devops 60766ae98e Desactivar temporalmente el stack de monitoring (Grafana+Prometheus)
Sin uso actual, y el host está evaluando capacidad de memoria para la
Etapa 2 del ADR 0006 (Meilisearch) con swap bajo presión. Se quita del
app-of-apps para liberar los recursos del namespace `monitoring`
(Grafana, Prometheus, Alertmanager, kube-state-metrics,
prometheus-operator -- estos dos últimos venían en CrashLoopBackOff
desde hace 37 días, ~8000 reinicios cada uno, conexión rechazada
contra el apiserver in-cluster -- y los 3 node-exporter).

Reversible: reagregar ambos archivos re-despliega el stack completo
vía Helm/kustomize sin pasos manuales. Sin pérdida de datos -- el
namespace monitoring no tenía ningún PVC, confirmado antes de borrar
(kubectl -n monitoring get pvc -> vacío).

Se agregó el finalizer resources-finalizer.argocd.argoproj.io a ambas
Applications (monitoring-app, monitoring-governance) antes de este
commit para que Argo CD borre en cascada los recursos desplegados al
podarlas, no solo el objeto Application -- sin eso quedarían pods
huérfanos corriendo sin liberar memoria.
2026-08-26 14:08:12 -05:00
devops 69467d5f4d Merge fix/commerce-root-redirect-app into main 2026-08-26 13:33:19 -05:00
devops 0189f2369e commerce-ingress: redirect root to /app instead of 404ing
commerce.cruzcloud.net (medusa-svc) has no route at "/" — Medusa only
serves /health, /app (admin), /store, etc. lab-agent's generic public
domain check flagged the resulting 404 as an anomaly every cycle even
though the service is healthy.

Adds a Traefik Middleware (redirectRegex) scoped to the commerce host
that 301s "/" to "/app", so the DNS/routing layer owns the entry point
instead of every client (including monitoring) having to know to
append /app manually. Regex includes the host explicitly because the
same Ingress also routes media.cruzcloud.net and shares
router.middlewares at the Ingress-annotation level.
2026-08-26 13:29:41 -05:00
gitea-actions f5d09059f0 chore(gitops): deploy Docs Portal v1.0.162 [skip ci] 2026-08-26 05:38:36 +00:00
devops 56ab9d1862 Merge branch 'fix/lab-agent-telegram-horario' into main
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 40s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 8m43s
Documenta el cambio a Telegram (silencioso para info, hilos al
recuperarse, log adjunto), la pausa nocturna 06:00-23:00 con su
tensión sin resolver con la ventana de medusa-deploy, la activación
del timer de systemd (60 min, bajado de 15 para reducir costo) con el
gotcha de Environment=HOME= para systemd --user, y el costo mensual
estimado actualizado.
2026-08-26 00:29:13 -05:00
devops ce8c96d3ee Actualizar costo estimado tras bajar el timer a 60 min
17 corridas/día en vez de 68. Nota importante: el costo por corrida
sube al perder la ventaja del cache tibio de Claude Code (~1h TTL,
corridas espaciadas 60 min lo pierden casi siempre) -- el ahorro real
es ~1.5-2x, no 4x. También documenta el trade-off de latencia de
detección (~70 min en vez de ~25 min hasta detectar un problema).
2026-08-26 00:28:01 -05:00
devops 10e2db5643 Documentar activación del timer y el gotcha de HOME= en systemd --user
Timer activo desde 2026-08-26. Documenta el hallazgo en vivo: el
manager de systemd --user de devops arranca con HOME=/DATA (el de
/etc/passwd), no con el override de /etc/profile.d/zz-devops-home.sh
que solo aplica a shells interactivas -- sin Environment=HOME=... en
la unidad, claude -p habría fallado la autenticación en cada corrida
del timer, silenciosamente. Se detectó y corrigió antes de activar,
corriendo el servicio manualmente vía systemctl --user start con el
entorno real como último paso de validación.
2026-08-26 00:25:53 -05:00
devops 7c6f3d9c27 Documentar notificación silenciosa, hilos y adjunto de log en Telegram
Actualiza la sección de Notificaciones: formato con íconos por tipo de
evento, log completo adjunto como documento, nivel "info" silencioso
(disable_notification), y respuestas en hilo cuando un recurso
accionado se recupera solo -- con la limitación conocida de que un
desbloqueo manual (borrando la entrada de state.json) pierde el
message_id y no queda threadeado. Validado en vivo con el canario.
2026-08-26 00:21:28 -05:00
devops e493f7d39e Documentar cambio a Telegram, pausa nocturna y costo estimado
Actualiza docs/arquitectura/agente-monitoreo.md: canal de notificación
cambiado de ntfy a Telegram Bot API (con la evidencia de la prueba de
entrega), horario activo 06:00-23:00 con su justificación de costo, la
tensión sin resolver con la ventana de medusa-deploy (00:00-06:00, no
se solapa con el horario activo del agente), y la estimación de costo
mensual (~US$60-65, basada en las corridas reales medidas).
2026-08-26 00:01:08 -05:00
gitea-actions 680f3ba068 chore(gitops): deploy Docs Portal v1.0.161 [skip ci] 2026-08-26 04:29:35 +00:00
devops 5014903c51 Merge branch 'fix/lab-agent-docs-arquitectura' into main
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 3m25s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 9m42s
Documentación de gobernanza y arquitectura del agente lab-agent:
lista blanca aprobada, diseño de seguridad (--allowedTools "" en vez
de un Bash tool con patrón, por la vulnerabilidad de coincidencia por
prefijo encontrada en vivo), autenticación por login de suscripción, y
evidencia completa de la prueba end-to-end del 2026-08-26.
2026-08-25 23:16:25 -05:00
devops 0052dfa1da Documentar lab-agent: lista blanca, diseño de seguridad y prueba e2e
Agrega docs/arquitectura/agente-monitoreo.md como referencia técnica y
documento de gobernanza del wrapper headless de diagnóstico/corrección
(scripts/lab-agent). Cubre la lista blanca de acciones automáticas
aprobada, por qué Claude corre con --allowedTools "" en vez de un Bash
tool con prefijo (coincidencia por prefijo permite inyección de
comandos, confirmado en vivo), el rediseño de autenticación a login de
suscripción en vez de API key, y la evidencia completa de la prueba
end-to-end del 2026-08-26 (DRY_RUN, ejecución real, rate limit/bloqueo,
2 bugs encontrados y corregidos).
2026-08-25 23:11:10 -05:00
devops 5ce7030c4d docs(adr): propuesta de motor de busqueda y facetas (Fase 5 Etapa 3)
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 32s
Build and Push Docs Portal / Construir y publicar Docs Portal (pull_request) Skipped
Etapa 1 (auditoria/propuesta, sin codigo) de la Etapa 3 de Fase 5:
motor de busqueda y filtros facetados reales, reemplazando el
filtrado/conteo 100% client-side que dejo la sub-fase 3 de Etapa 2.

Verificado contra documentacion oficial vigente (no memoria de
entrenamiento):
- Medusa no tiene modulo de busqueda propio integrado con
  Meilisearch; el Search Module nativo llego en Medusa 2.19, y el
  plugin comunitario @rokmohar/medusa-plugin-meilisearch se conecta a
  el solo desde su version ^2.0.0 (requiere Medusa >=2.19). Nuestro
  backend corre Medusa 2.18.0 -- la version compatible es la linea
  ^1.4.1 del plugin (Medusa >=2.15 <2.19), documentada como deuda
  tecnica explicita si se sube Medusa mas adelante.
- Impacto de recursos evaluado contra el estado real del host (swap
  al 100%, mismo tipo de contencion que el incidente de Gitea):
  Meilisearch recomendado sobre Typesense por menor huella de RAM y
  por ser el unico con integracion Medusa mantenida para nuestra
  version -- comparacion mantenida breve porque la diferencia es
  clara, sin profundizar mas de lo necesario.
- Diseno de indexacion (campos buscables/filtrables/ordenables),
  sincronizacion (mismo patron de subscriber por eventos que
  revalidate-storefront.ts, sumando product-category.* al set de
  eventos) y UI de facetas/autocompletado (reutiliza catalog-client.tsx
  y CatalogProvider.searchProducts ya existentes, sin componentes
  nuevos).

Cross-referencia agregada en ADR 0004 (que ya habia identificado
Meilisearch como paso natural) y entrada nueva en mkdocs.yml.

Etapa 2 (implementacion) NO arranca hasta aprobacion explicita de esta
propuesta.
2026-08-25 18:10:28 -05:00
gitea-actions 3b2f99c7a0 chore(gitops): deploy Docs Portal v1.0.159 [skip ci] 2026-08-25 21:08:06 +00:00
devops 00d7f58c9a Merge pull request 'docs(adr): marcar Etapa 2 del ADR 0005 como completa' (#43) from fix/adr0005-marcar-etapa2-completa into main
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 6m17s
2026-08-25 21:01:32 +00:00
devops 6f24109d91 docs(adr): marcar Etapa 2 del ADR 0005 como completa
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 21s
Build and Push Docs Portal / Construir y publicar Docs Portal (pull_request) Skipped
Estado seguia diciendo "propuesta -- pendiente de aprobacion antes de
Etapa 2" aunque las 3 sub-fases (datos/categorias, navegacion,
componentes visuales -- PRs #29-#42) ya estan implementadas y en
produccion desde el 2026-08-25. Actualiza el header de estado y
reemplaza la seccion 6 (antes "Proximos pasos" pendiente de
aprobacion) por el resumen real de lo ejecutado, dejando explicito que
sigue pendiente borrar el usuario admin temporal de la auditoria y que
no hay Etapa 3 definida todavia.
2026-08-25 16:00:40 -05:00
devops edd14ca790 chore: release v1.0.157 [skip ci] 2026-08-25 20:29:09 +00:00
devops b6e3bddbc6 Merge pull request 'fix(ecommerce): corregir error de tipos en indice de tile promocional' (#42) from fix/etapa2-subfase3-fix-promo-index-type into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m29s
2026-08-25 20:19:26 +00:00
devops 3c0ce03e09 fix(ecommerce): corregir error de tipos en indice de tile promocional
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 25s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
tsconfig tiene noUncheckedIndexedAccess -- promoRoots[promoIndex %
promoRoots.length] tipa como Category | undefined aunque en runtime
siempre es valido (promoRoots.length > 0 ya se verifico antes de este
punto). Extraido a una constante con guard explicito para que TS
angoste el tipo. Sin cambio de comportamiento.

Detectado por el build real en main tras el merge de #41 -- no se
puede correr tsc localmente en esta maquina (ver notas de sesiones
anteriores), asi que esto solo lo valida el CI post-merge.
2026-08-25 15:18:12 -05:00
devops e7699befc5 Merge pull request 'fix(ecommerce): tiles promocionales intercalados en el grid del catalogo' (#41) from fix/etapa2-subfase3-tiles-promocionales into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 5m10s
2026-08-25 20:10:32 +00:00
devops 44d6e10ea2 chore: release v1.0.154 [skip ci] 2026-08-25 20:09:39 +00:00
devops f5de074ea1 Merge pull request 'fix(ecommerce): breadcrumb real + conteos por filtro en el catalogo' (#40) from fix/etapa2-subfase3-facetas-breadcrumb into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 26s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 13m4s
2026-08-25 19:56:04 +00:00
devops 8e74d0b516 chore: release v1.0.153 [skip ci] 2026-08-25 19:55:06 +00:00
devops e95ac086f0 Merge pull request 'fix(ecommerce): mensaje de urgencia por inventario real' (#39) from fix/etapa2-subfase3-urgencia-inventario into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m4s
2026-08-25 19:43:26 +00:00
devops 6a78257d71 chore: release v1.0.149 [skip ci] 2026-08-25 19:42:14 +00:00
devops 7a67ba0743 feat(ecommerce): tiles promocionales intercalados en el grid del catalogo
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 23s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
ADR 0005 seccion 3 (patron Walmart), ultimo item de la sub-fase de
componentes visuales: "grid de catalogo con tiles promocionales
intercalados, no solo tarjetas de producto homogeneas".

`category-tiles.tsx` ya era ese mismo primitivo visual (usado en el
home) -- se extrajo el tile individual a un componente exportado
`PromoTile` (mismo look, sin cambio visual en el home) para
reutilizarlo en `catalog-client.tsx`: un tile cada
PROMO_TILE_INTERVAL=8 productos, ciclando por las categorias raiz
(excluye la raiz que el usuario ya esta viendo via el breadcrumb de
#40). Si no hay jerarquia de categorias disponible, la grilla queda
igual que antes (solo productos) -- degradacion sin romper nada.

Depende de #40 (breadcrumb + categoryNodes en CatalogClient) -- mismo
archivo, se apila sobre esa rama en vez de main.
2026-08-25 14:40:52 -05:00
devops bc566b1fc0 feat(ecommerce): breadcrumb real + conteos por filtro en el catalogo
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 2m32s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
ADR 0005 seccion 3 (patron Amazon), primer item de "Filtros facetados
en sidebar con breadcrumb de categoria":

- Breadcrumb (Inicio > raiz > hija) armado caminando parentId sobre la
  jerarquia real de categorias (misma que ya consume el mega-menu de
  navbar.tsx), no un dato nuevo -- CatalogClient ahora recibe el
  arreglo `categories: Category[]` completo ademas del
  `categoryGroups` ya existente
- Conteo por valor en cada filtro de la sidebar (Categoria/Marca/Edad),
  ej. "Marca (12)": se calcula client-side sobre los productos que ya
  pasan el resto de filtros activos, cada dimension excluyendose a si
  misma (patron faceted-count estandar). Sin Meilisearch por ahora,
  como preveia el ADR ("server-side simple por ahora")

Filtrado existente refactorizado a funciones matchesX() puras y
reutilizadas tanto por `filtered` como por los conteos, sin cambiar el
comportamiento de filtrado ya validado.
2026-08-25 14:37:29 -05:00
devops 8fc9df3b50 feat(ecommerce): mensaje de urgencia por inventario real
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 26s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Expone ProductVariant.inventoryQuantity (ya calculado desde
variant.inventory_quantity en Medusa, hoy colapsado al booleano
available) y lo usa para mostrar "Quedan N" cuando el stock real es
bajo (<=5), tanto en la tarjeta de producto como en la ficha. null
indica inventario no gestionado (backorder / manage_inventory=false),
donde nunca se muestra un numero. ADR 0005 seccion 3 (patron eBay).

Catalogo mock actualizado (data/products.json) con
inventoryQuantity=null en todas las variantes para seguir tipando
contra Product[] via `satisfies`.
2026-08-25 14:33:14 -05:00
devops 707a05cc6e Merge pull request 'fix(ecommerce): selector de variante inline en tarjeta de producto' (#38) from fix/etapa2-subfase3-swatches-inline into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 25s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 10m37s
2026-08-25 19:31:15 +00:00
devops e20f3e1ddf feat(ecommerce): selector de variante inline en tarjeta de producto
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 39s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Reutiliza Product.variants[].{name,value,available}, ya expuesto por el
provider de Medusa (variant.options[0]), para swatches clicables en la
tarjeta del catalogo/home -- no solo en la ficha de producto. Solo se
muestra cuando el producto tiene mas de una variante; la variante
seleccionada se usa al agregar al carrito. ADR 0005 seccion 3 (patron
eBay), cero cambio de modelo de datos.
2026-08-25 14:29:50 -05:00
devops 5b03066ed2 chore: release v1.0.147 [skip ci] 2026-08-25 19:24:43 +00:00
devops 78cc5ef93b Merge pull request 'fix(ecommerce): badge antes/ahora en tarjetas de producto' (#37) from fix/etapa2-subfase3-badges-antes-ahora into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 11m41s
2026-08-25 19:12:37 +00:00
devops 9a8e12fa8b feat(ecommerce): badge antes/ahora en tarjetas de producto
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 22s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Reutiliza Product.compareAtPrice (ya calculado en el provider de Medusa
via original_amount > calculated_amount) para mostrar precio tachado +
badge de descuento en la grilla/catalogo, no solo en la ficha de
producto. ADR 0005 seccion 3, patron Walmart -- cero cambio de modelo
de datos.
2026-08-25 14:10:33 -05:00
devops e36525e0d8 chore: release v1.0.145 [skip ci] 2026-08-25 19:05:55 +00:00
devops b00dd14f76 Merge pull request 'fix(ecommerce): mega-menu de Departamentos multi-columna en el navbar' (#36) from fix/etapa2-subfase3-visuales into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 28s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 15m21s
2026-08-25 18:50:11 +00:00
devops 97f6fb91d8 feat(ecommerce): mega-menu de Departamentos multi-columna en el navbar
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 31s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Reemplaza la fila horizontal de pildoras por categoria (ADR 0005 seccion
3, patron Walmart) por un trigger unico "Departamentos" que abre un
panel multi-columna con las 6 categorias raiz y sus hijos, activado por
hover (mouse) o tap (touch), con cierre por click-fuera y Escape.
2026-08-25 13:44:38 -05:00
devops 40f8628e5d chore: release v1.0.143 [skip ci] 2026-08-25 05:12:02 +00:00
devops e7533b402d Merge pull request 'fix(ecommerce): forzar renderizado dinamico en home y layout raiz' (#35) from fix/etapa2-subfase2-home-force-dynamic into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 1m6s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 16m48s
2026-08-25 04:54:22 +00:00
devopsandClaude Sonnet 5 206571627b fix(ecommerce): forzar renderizado dinamico en home y layout raiz
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 29s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Bug preexistente (no introducido en esta sesion, solo expuesto): "/" se
prerenderizaba en build time dentro del Dockerfile, donde
HEADLESS_PROVIDER/MEDUSA_* no existen todavia (solo se inyectan en runtime
via el Deployment de k8s) -- el provider caia a "mock" y ese HTML quedaba
cacheado 1 año (confirmado en vivo: x-nextjs-prerender:1,
Cache-Control: s-maxage=31536000, x-nextjs-cache:HIT). El carrusel de
"Novedades" del home ya mostraba productos mock desde antes; recien se
noto con el nav data-driven de la sub-fase 2 porque las categorias mock
("Figuras"/"Peluches"/"Mochilas") son obviamente distintas a las reales.
/catalog y /product/[slug] no sufren esto porque ya son dinamicos
(searchParams / params fuerzan SSR).

export const dynamic = "force-dynamic" en app/page.tsx y app/layout.tsx
(el layout comparte el fetch del Navbar con TODAS las rutas -- forzarlo
ahi tambien protege contra que una pagina futura reintroduzca el mismo
bug). Decision del usuario: priorizar datos reales sobre el cacheo
estatico de 1 año.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013dunTcReWMKhWgCPkvAVHD
2026-08-24 23:42:57 -05:00
devops d64218f45f chore: release v1.0.141 [skip ci] 2026-08-25 04:03:03 +00:00
devops 7695ca0dea Merge pull request 'fix(ecommerce): tipar defaultTile igual que las entradas de tileContent' (#34) from fix/etapa2-subfase2-category-tiles-type into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 28s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 21m58s
2026-08-25 03:40:36 +00:00
devopsandClaude Sonnet 5 bd956328db fix(ecommerce): tipar defaultTile igual que las entradas de tileContent
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 49s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
next build fallaba en tsc: defaultTile no tenia la propiedad opcional
image, asi que la union de tipos entre tileContent[handle] y defaultTile
perdia esa propiedad y content.image no compilaba. Se extrae el tipo
TileContent y se anota defaultTile con el, igual que tileContent.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013dunTcReWMKhWgCPkvAVHD
2026-08-24 22:37:24 -05:00
devops 20bc559a6c Merge pull request 'fix(ci): actualizar guard de navegacion del catalogo tras PR #32' (#33) from fix/etapa2-subfase2-ci-nav-check into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 12m14s
2026-08-25 03:17:24 +00:00
devopsandClaude Sonnet 5 2abfec57ae fix(ci): actualizar guard de navegacion del catalogo tras PR #32
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 30s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
El guard de "Validar navegación real del catálogo" en build.yaml seguia
pinneado a la implementacion vieja de navbar.tsx: greppeaba literalmente
"collection=Marvel", "category=Mochilas" y "currentCollection", que eran
exactamente los valores hardcodeados que PR #32 elimino por estar rotos
(ya no correspondian a ningun dato real desde que se quito
legacyProductMeta en PR #29). El guard quedo validando el bug, no la
correccion, y bloqueo el build post-merge de PR #32.

Ahora valida el comportamiento real nuevo: que el nav arma su arbol
desde datos reales (buildCategoryTree), que linkea por nombre real de
categoria (encodeURIComponent(root.name)) y que expone el item activo
(aria-current={isActive(...)}). El check de remontaje en
app/catalog/page.tsx tambien se actualizo: la key ya no incluye
":${section}" porque ese query param nunca se usaba (dead code
eliminado en PR #32).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013dunTcReWMKhWgCPkvAVHD
2026-08-24 21:36:36 -05:00
devops f1f5f3612b Merge pull request 'fix(ecommerce): nav y tiles data-driven contra la jerarquia real de categorias' (#32) from fix/etapa2-subfase2-navegacion-datadriven into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 23s
2026-08-25 02:27:18 +00:00
devopsandClaude Sonnet 5 8742ce714c fix(ecommerce): nav y tiles data-driven contra la jerarquia real de categorias
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 35s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Sub-fase 2 de Etapa 2 (ADR 0005 seccion 6). Navbar y CategoryTiles usaban
arrays hardcodeados (Figuras/Peluches/Mochilas/Marvel) que ya no coinciden
con los nombres reales de categoria en Medusa desde que se elimino
legacyProductMeta (PR #29) -- todos esos links de navegacion quedaron
rotos silenciosamente (0 productos al filtrar).

- lib/headless: nuevo tipo Category + metodo getCategories() en
  CatalogProvider (medusa via /store/product-categories, mock deriva de
  su catalogo plano, shopify sin implementar como el resto de sus metodos).
- Navbar: arbol real de categorias (6 raices/21 nodos), dropdown de
  hijos por raiz en desktop, lista anidada en el sheet movil. Colecciones
  del sheet ahora se derivan de los productos reales en vez de un array
  hardcodeado con 2 nombres obsoletos (Marvel/Frozen vs. los reales
  Universo Marvel/Reino de Frozen).
- CategoryTiles: tiles de home por categoria raiz real; imagen solo donde
  ya existe asset, gradiente honesto donde no (no hay fotografia para las
  6 raices todavia, deuda explicita del ADR).
- catalog-client/catalog page: cuando el query param `category` es una
  raiz con hijos, se expande a los nombres reales de sus hijos para que
  el filtro (igualdad exacta de producto.category) siga funcionando --
  sin esto, tiles/nav apuntando a una raiz mostrarian 0 productos porque
  ningun producto tiene la raiz asignada directamente.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013dunTcReWMKhWgCPkvAVHD
2026-08-24 21:21:18 -05:00
devops 0508c6328e chore: release v1.0.135 [skip ci] 2026-08-25 01:56:27 +00:00
devops 7485457cf4 Merge pull request 'fix(ci,ecommerce): escanear el manifiesto renderizado y endurecer readOnlyRootFilesystem del frontend' (#31) from fix/frontend-trivy-iac-and-readonly-fs into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 19m38s
2026-08-25 01:36:36 +00:00
devops 2670727cfa fix(ci,ecommerce): escanear el manifiesto renderizado y endurecer readOnlyRootFilesystem del frontend
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 23s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
- Trivy IaC escaneaba solo el patch aislado de Kustomize
  (workloads/ecommerce/frontend/patches/deployment.yaml), no lo que
  realmente corre en el clúster. Eso generaba falsos positivos en todo
  lo heredado del base remoto de platform-templates (runAsNonRoot,
  seccompProfile) porque el patch nunca los repite. Se instala kubectl,
  se renderiza el kustomization completo y se escanea ese output.
- readOnlyRootFilesystem pasa de false a true en el contenedor del
  frontend: revisando next.config.ts (optimización de imágenes) y
  app/api/revalidate/route.ts (revalidateTag sin cache handler custom),
  la imagen sí escribe a ./.next/cache en runtime. Se agregan volúmenes
  emptyDir para .next/cache y /tmp.
2026-08-24 20:35:08 -05:00
devops 3147cafaff chore: release v1.0.133 [skip ci] 2026-08-25 01:23:38 +00:00
devops cb5a08e7cc Merge pull request 'fix(ci): timeout de Trivy insuficiente en el scan de imagen del frontend' (#30) from fix/frontend-trivy-scan-timeout into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 17m24s
2026-08-25 00:56:15 +00:00
28 changed files with 1421 additions and 308 deletions

No files matched your search

+42 -12
View File
@@ -127,6 +127,7 @@ jobs:
env:
APP_DIR: workloads/ecommerce
MANIFEST_FILE: workloads/ecommerce/frontend/patches/deployment.yaml
KUSTOMIZE_DIR: workloads/ecommerce/frontend
IMAGE_NAME: gitea.cruzcloud.net/devops/ecommerce-frontend
steps:
@@ -213,22 +214,25 @@ jobs:
exit 1
}
grep -q 'currentCollection' "${NAVBAR_FILE}" || {
echo "ERROR: navbar.tsx no calcula currentCollection."
# Desde Etapa 2 sub-fase 2 (2026-08-25) el nav es data-driven
# contra el arbol real de categorias de Medusa, no un array
# hardcodeado -- esta version anterior del check no detectaba
# que "category=Mochilas"/"collection=Marvel" eran strings
# hardcodeadas que ya no correspondian a ningun dato real (rotas
# en silencio desde que se elimino legacyProductMeta, PR #29).
# "currentCollection" tampoco existe mas: las colecciones ya no
# se usan para resaltar el item activo del menu de categorias.
grep -q 'buildCategoryTree' "${NAVBAR_FILE}" || {
echo "ERROR: navbar.tsx ya no arma el arbol de categorias desde datos reales."
exit 1
}
grep -q 'collection=Marvel' "${NAVBAR_FILE}" || {
echo "ERROR: falta collection=Marvel en navbar.tsx."
grep -q 'encodeURIComponent(root.name)' "${NAVBAR_FILE}" || {
echo "ERROR: navbar.tsx ya no linkea por el nombre real de categoria."
exit 1
}
grep -q 'category=Mochilas' "${NAVBAR_FILE}" || {
echo "ERROR: falta category=Mochilas en navbar.tsx."
exit 1
}
grep -q 'aria-current={active ? "page" : undefined}' "${NAVBAR_FILE}" || {
grep -q 'aria-current={isActive(' "${NAVBAR_FILE}" || {
echo "ERROR: navbar.tsx no expone correctamente el menú activo."
exit 1
}
@@ -257,7 +261,7 @@ jobs:
echo "Validando remontaje por parámetros: ${PAGE_FILE}"
grep -q 'key={`${category}:${collection}:${query}:${section}`}' "${PAGE_FILE}" || {
grep -q 'key={`${category}:${collection}:${query}`}' "${PAGE_FILE}" || {
echo "ERROR: app/catalog/page.tsx no remonta CatalogClient al cambiar la URL."
exit 1
}
@@ -276,6 +280,32 @@ jobs:
chmod +x /tmp/trivy
/tmp/trivy version
- name: Instalar kubectl
shell: bash
run: |
set -euo pipefail
KUBECTL_VERSION="v1.31.2"
curl -sSfL \
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \
-o /tmp/kubectl
chmod +x /tmp/kubectl
/tmp/kubectl version --client
# ${MANIFEST_FILE} es solo el patch de Kustomize del Deployment, no el
# manifiesto que de verdad corre en el clúster -- escanearlo directo
# generaba falsos positivos en todo lo que hereda del base remoto de
# platform-templates (runAsNonRoot, seccompProfile), porque ese patch
# nunca repite lo que ya trae el base (visto en vivo 2026-08-24:
# comparando el patch aislado contra el output real de
# "kubectl kustomize", 2 de 5 hallazgos desaparecían). Renderizamos el
# kustomization completo (${KUSTOMIZE_DIR}, que sí trae el base vía
# git remoto) y escaneamos eso.
- name: Renderizar manifiesto Kustomize
shell: bash
run: |
set -euo pipefail
/tmp/kubectl kustomize "${KUSTOMIZE_DIR}" > /tmp/frontend-rendered.yaml
# Escanea el manifiesto Kubernetes real del frontend (no la imagen):
# contenedores como root, falta de resource limits, falta de
# readiness/liveness probes, etc. Informativo por ahora — no bloquea
@@ -287,7 +317,7 @@ jobs:
/tmp/trivy config \
--severity CRITICAL,HIGH,MEDIUM \
--exit-code 0 \
"${MANIFEST_FILE}"
/tmp/frontend-rendered.yaml
# Instalado directo con pip (no como container aparte): el runner de
# Gitea ejecuta cada job ya dentro de un contenedor propio que habla
-40
View File
@@ -1,40 +0,0 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: monitoring-app
namespace: argocd
spec:
project: default
source:
repoURL: https://prometheus-community.github.io/helm-charts
chart: kube-prometheus-stack
targetRevision: 58.3.3
helm:
parameters:
- name: prometheusOperator.admissionWebhooks.patch.ttlSecondsAfterFinished
value: "60"
valuesObject:
grafana:
ingress:
enabled: true
ingressClassName: traefik
hosts:
- grafana.cruzcloud.net
destination:
server: https://kubernetes.default.svc
namespace: monitoring
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
-20
View File
@@ -1,20 +0,0 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: monitoring-governance
namespace: argocd
spec:
project: default
source:
repoURL: https://gitea.cruzcloud.net/devops/platform-infra.git
path: monitoring-governance
targetRevision: main
destination:
server: https://kubernetes.default.svc
namespace: monitoring
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
@@ -0,0 +1,307 @@
# Agente de diagnóstico/corrección (lab-agent)
Wrapper headless (`scripts/lab-agent/lab-agent.sh`) que corre `claude -p`
periódicamente para diagnosticar el estado del lab y, solo para un
conjunto explícito y aprobado de recursos, ejecutar una acción
correctiva de bajo riesgo. Este documento es tanto referencia técnica
como decisión de gobernanza — la lista blanca de abajo es lo que
autoriza al agente a actuar sin intervención humana, y debe poder
auditarse.
Estado: **timer de systemd ACTIVO desde 2026-08-26** (`lab-agent.timer`,
usuario `devops`, cada 60 min entre 06:00–23:00 hora local — bajado de
15 a 60 min el mismo día para reducir costo). Script,
Telegram y las unidades systemd, todo validado en vivo antes de
activar.
## Lista blanca de acciones correctivas automáticas
Aprobada 2026-08-25, antes de escribir una sola línea de código.
### 1. `kubectl rollout restart deployment/<nombre>`
| Condición | Valor |
|---|---|
| Trigger | Deployment con `readyReplicas < replicas` o condición `Available=False` durante **≥10 minutos consecutivos** |
| Namespace elegible | `ecommerce` (todos los deployments detectados ahí en el momento de la corrida) |
| Excepción | `medusa-deploy` — el deployment real de "commerce-backend" en la terminología de `CLAUDE.md` (confirmado en vivo 2026-08-25: no existe un deployment llamado literalmente `commerce-backend`) — solo elegible si además la hora **local** del host está entre **00:00–06:00** |
| Namespaces excluidos siempre | `argocd`, `kube-system`, cualquier otro namespace de plataforma |
### 2. `docker restart <contenedor>`
| Condición | Valor |
|---|---|
| Contenedores elegibles | **Únicamente `gitea-runner`** |
| Trigger | Contenedor no está en estado `running` durante ≥10 min, **o** la API de Gitea Actions reporta un run en estado `running` atascado por más de 10 min sin terminar (señal del incidente de runner zombie del 2026-08-16) |
| Degradación segura | Si no hay `GITEA_TOKEN_FILE` configurado o la API falla, esa señal se omite — nunca dispara una acción sobre una lectura no verificada, solo queda la señal de "contenedor caído" |
| Excluidos siempre | `gitea`, `nginxproxymanager`, `cloudflared` |
### 3. Fuera de alcance automático — SIEMPRE (solo diagnóstico + notificación)
- Borrar cualquier recurso, escalar réplicas, tocar `ConfigMap`/`Secret`/configuración.
- Reiniciar `gitea`, `nginxproxymanager`, `cloudflared`.
- Cualquier acción sobre `medusa-deploy` fuera de la ventana 00:00–06:00.
- Cualquier namespace/deployment no detectado explícitamente en `ecommerce`.
### 4. Límites obligatorios
- **Rate limit:** máx. 1 acción correctiva por recurso cada 30 min.
- **Sin reintento silencioso:** tras un primer intento cuyo problema no
se resuelve, el recurso queda `blocked` — solo notificación, hasta
que un humano limpie la entrada en `state.json` o el recurso se
recupere solo.
- **Logging completo:** cada decisión (actúe o no) se registra en
`actions.jsonl` con timestamp, recurso, decisión, resultado y motivo.
- **Notificación siempre:** toda acción correctiva o anomalía dispara
notificación — nunca es silenciosa.
## Diseño de seguridad: por qué Claude nunca ejecuta comandos
`claude -p` se invoca con `--allowedTools ""` (cero herramientas) y
`--json-schema`, así que el modelo **nunca** ejecuta un comando —
solo devuelve una decisión estructurada
(`{"actions":[{"resource","decision","reason"}], "summary"}`). El
script bash es el único que ejecuta `kubectl`/`docker`, y solo después
de revalidar la decisión contra su propio cálculo de elegibilidad —
nunca confía en el JSON de Claude por sí solo.
Se descartó dar a Claude un `Bash` tool con un patrón tipo
`Bash(kubectl rollout restart deployment/medusa-deploy -n ecommerce)`
porque se comprobó en vivo (2026-08-25) que `--allowedTools` hace
**coincidencia por prefijo, no exacta**: un patrón `Bash(echo pintest)`
también permite ejecutar `echo pintest && echo INJECTED` o
`echo pintest; rm -rf /`. Para un comando root-capaz eso no es un
límite seguro. Con `--allowedTools ""`, el peor caso posible es que
Claude proponga una decisión incorrecta en el JSON — y el wrapper la
rechaza si el recurso no está en su propia lista de elegibles.
El prompt también advierte contra prompt injection: los datos de
diagnóstico (estado de pods, logs) podrían contener texto arbitrario
si algo en el cluster está comprometido, e instruye a Claude a ignorar
cualquier instrucción incrustada ahí.
## Autenticación: login de suscripción, no API key
`ANTHROPIC_API_KEY_FILE` queda vacío por defecto — `claude -p` usa el
login de suscripción ya activo del usuario que corre el script
(`~/.claude/.credentials.json`), el mismo que usa una sesión
interactiva normal. Esta decisión se tomó en vivo (2026-08-26) al
confirmar que no había una API key real disponible; el diseño original
pedía un secreto root-only para la API key, pero se simplificó porque:
1. No hacía falta una API key aparte — la suscripción ya cubre esto.
2. Elimina la necesidad de crear secretos root-only vía `sudo` (que
además está bloqueado para una sesión de Claude Code interactiva
por diseño — ver `feedback_scoped_root_access_pattern` en memoria
del proyecto).
**Consecuencia directa:** el servicio systemd (cuando se active) debe
correr como usuario `devops`, no como `root` — root no tiene acceso a
ese login. `CLAUDE_BIN`/`KUBECTL_BIN` apuntan por ruta absoluta a
`/DATA/AppData/home-devops/.local/bin/` porque ese directorio no está
en el `PATH` de root ni de systemd (confirmado en vivo: `sudo which
claude` no lo encuentra).
El único secreto que queda es `GITEA_TOKEN_FILE` (token de Gitea
Actions, opcional, scope lectura), dueño `devops`, `600` — sin sudo.
## Prueba end-to-end (2026-08-26)
Ejecutada contra el cluster real, rama `fix/lab-agent-diagnostico-headless`.
Umbral bajado a 2 minutos solo para esta prueba (`UNHEALTHY_THRESHOLD_MIN`,
valor real en producción: 10). Canario desechable: `deployment/test-agent-canary`
en `ecommerce`, imagen `busybox:stable` con `command: ["sh","-c","exit 1"]`
— garantiza `CrashLoopBackOff` predecible, sin relación con
`catalog-api-deploy`/`frontend-deploy`/`medusa-deploy`.
### Bugs encontrados y corregidos durante la prueba
1. **Subshell perdía el resultado de la acción.** `execute_corrective_action`
se invocaba como `result="$(execute_corrective_action "$resource")"` —
al correr en un subshell, las variables `LAST_ACTION_CMD`/`STDOUT`/`STDERR`
nunca volvían al script principal → crash con `unbound variable`.
Corregido: la función ahora devuelve éxito/fallo por exit status, invocada
directamente (sin `$(...)`) para que sus variables globales persistan.
2. **DRY_RUN contaminaba el estado real.** El wrapper marcaba `blocked=true`
en `state.json` incluso en modo simulación, lo que habría bloqueado
incorrectamente una corrida real posterior sobre el mismo recurso.
Corregido: la actualización de `state.json` (bloqueo/rate-limit) solo
ocurre fuera de `DRY_RUN`.
3. **`claude`/`kubectl` fuera del PATH de root/systemd** — ver sección de
autenticación arriba. Corregido con `CLAUDE_BIN`/`KUBECTL_BIN` por ruta
absoluta.
### Evidencia por paso
**DRY_RUN (elegibilidad + decisión, sin tocar el cluster):**
- 1ª corrida (recién detectado): `elegibilidad=not_eligible:bajo_umbral(0m<2m)`
→ Claude devolvió `notify_only` para los 5 recursos.
- 2ª corrida (&gt;2 min después): `elegibilidad=eligible` para
`test-agent-canary` → Claude devolvió `decision:"restart"` solo para
ese recurso, `notify_only` para los otros 4 (sanos).
- Log: `ACCIÓN EJECUTADA: kubectl -n ecommerce rollout restart
deployment/test-agent-canary -> OK`, pero verificado en el cluster real
que **no hubo restart** (sin anotación `restartedAt`, mismo pod, misma
edad). `actions.jsonl` registra `"dry_run": true`.
**Ejecución real (`--live`):**
- Misma decisión de Claude (`restart` para el canario, `notify_only` para
el resto).
- Verificado en el cluster: nueva anotación
`kubectl.kubernetes.io/restartedAt` en el deployment, evento
`ScalingReplicaSet` de un ReplicaSet **nuevo**, pod nuevo en otro nodo.
- `actions.jsonl`: `"result": "success", "dry_run": false`.
- `state.json`: `last_action_at` seteado, `blocked: true`.
**Rate limit / sin reintento silencioso:**
- El canario (siempre crashea por diseño) volvió a estar unhealthy sin
intervención extra.
- Corrida siguiente: `elegibilidad=not_eligible:blocked_tras_intento_previo_sin_recuperacion`.
- Claude devolvió `notify_only` para **todos** los recursos, incluido el
canario — ningún segundo restart propuesto ni ejecutado.
- `state.json` sin cambios (mismo `last_action_at` que la corrida real
anterior) — confirma que no hubo reintento.
- Notificación real entregada vía ntfy.sh, confirmada leyendo el propio
historial del topic (no solo el log del script).
**Limpieza:** `deployment/test-agent-canary` eliminado, entrada
correspondiente en `state.json` borrada. Los logs de la prueba
(`actions.jsonl`, `run-*.log`) se conservan como evidencia auditable —
no se purgaron.
## Notificaciones
**Canal: Telegram Bot API** (reemplaza a ntfy, cambiado 2026-08-26 por
preferencia — no por costo, ver sección de costo abajo). Bot creado
manualmente vía [@BotFather](https://t.me/BotFather); el token vive en
`TELEGRAM_BOT_TOKEN_FILE` (dueño `devops`, `600` — **no** root-only,
mismo criterio que `GITEA_TOKEN_FILE`: el servicio corre como `devops`
por el login de suscripción, un archivo `root:root` le sería ilegible).
`TELEGRAM_CHAT_ID` no es secreto, va directo en `lab-agent.env`.
El mensaje se manda como texto plano, sin `parse_mode`: el motivo que
devuelve Claude es texto libre y puede traer `_`, `*`, `[`, `]` — con
Markdown/MarkdownV2 esos caracteres rompen el parser de Telegram (`400
Bad Request`) si no se escapan con cuidado. Se prioriza entrega
confiable sobre formato.
**Validado en vivo 2026-08-26** con el mismo código que usa
`notify_send()` (lectura del token, `sendMessage` vía `curl
--data-urlencode`, formato `[title]\nmessage`): entrega confirmada
(`"ok":true`) y contenido recibido tal cual en Telegram. Gotcha
encontrado durante la prueba: el primer intento falló con `400 chat not
found` porque el `chat_id` pegado correspondía a otro bot — el `chat_id`
de un usuario es el mismo across bots, pero Telegram igual devuelve
`chat not found` si *ese bot puntual* nunca recibió un mensaje de esa
persona. Se resolvió confirmando el `chat_id` real vía `getUpdates` del
bot correcto.
**Formato:** cada notificación lleva un ícono según su naturaleza
(✅ acción ok, 🛑 acción falló/rechazada, 🚨 alerta de seguridad,
❌ error operativo, 👀 solo diagnóstico) y el log completo de esa
corrida va adjunto como documento (`sendDocument`), no solo como ruta —
útil para revisar desde el celular sin acceso a shell.
**Notificación silenciosa y respuestas en hilo (2026-08-26):** el nivel
`info` (nada elegible, todo diagnóstico rutinario) se manda con
`disable_notification=true` — no interrumpe; acciones, fallas y alertas
de seguridad sí suenan. Cuando se ejecuta una acción correctiva, el
`message_id` de Telegram queda guardado por recurso en `state.json`
(`telegram_message_id`); si ese recurso se recupera solo en una corrida
posterior, se manda un aviso silencioso como **respuesta** (hilo) al
mensaje original antes de limpiar el estado — agrupa visualmente todo
un incidente. Limitación conocida: si un humano desbloquea el recurso a
mano borrando su entrada de `state.json` (ver más abajo), el
`message_id` se pierde con esa entrada, así que ese caso puntual no
queda threadeado — solo el "se recuperó solo" está cubierto hoy.
Validado en vivo con el canario: acción real → `message_id` capturado →
canario parcheado a una imagen sana → reply en hilo confirmado,
silencioso, al recuperarse.
## Horario activo y costo
**Pausa nocturna (`ACTIVE_HOURS_START`/`END`, hora local del host):**
activo `06:00`–`23:00`, pausado `23:00`–`06:00`. El script chequea esto
como lo primero que hace tras inicializar logging — si está fuera de
horario, sale **antes** de tocar `kubectl`/`docker`/`curl` o invocar
`claude -p`, así que la pausa ahorra el costo completo de la corrida,
no solo la notificación. El timer de systemd además solo dispara dentro
de esa ventana (`OnCalendar=*-*-* 06..22:00:00`) — la doble
verificación es deliberada: si alguien edita la unidad de systemd sin
tocar `lab-agent.env` (o corre el script a mano), el chequeo interno
sigue protegiendo.
**Tensión sin resolver con la ventana de `medusa-deploy`:** la ventana
de auto-restart para `medusa-deploy` (`COMMERCE_BACKEND_WINDOW_*`,
`00:00`–`06:00`) **no se solapa** con el horario activo del agente
(`06:00`–`23:00`). Esto significa que, en la práctica, `medusa-deploy`
nunca es candidato real a auto-restart: la única ventana en la que la
política lo permitiría es exactamente cuando el agente está pausado y
ni siquiera corre. La regla sigue viva en el código (por si el horario
activo cambia más adelante) pero hoy es efectivamente inalcanzable.
Pendiente de decisión: mover la ventana de `medusa-deploy` a que caiga
dentro del horario activo, o aceptar que ese recurso queda cubierto
solo por diagnóstico/notificación manual.
**Estimación de costo mensual — actualizada 2026-08-26, frecuencia
bajada de 15 a 60 min explícitamente para reducir costo:**
| | |
|---|---|
| Corridas/día (17h activas ÷ 60 min) | 17 |
| Costo por corrida | ~US$0.04–0.12 — **más alto por corrida que a 15 min**: el cache de prompt de Claude Code dura ~1h, y con corridas espaciadas 60 min casi siempre expira entre una y la siguiente, así que la mayoría de las corridas pagan cache "fría" en vez de "tibia" |
| **Estimado mensual (× 30 días)** | **~US$20–60** (punto medio ~US$35) |
Ojo con la intuición: bajar la frecuencia 4× (68→17 corridas/día) NO
baja el costo 4× — el ahorro real es más cercano a 1.5–2× frente a la
estimación anterior (~US$60–65/mes), porque cada corrida individual
sale más cara al perder la ventaja del cache tibio. Sigue siendo menos
costo total, solo que no linealmente. Basado en pocas corridas reales
medidas — revisar el costo real después de la primera semana en
producción (Telegram/Gitea Actions no cambian este número, el costo es
100% de la API de Claude).
**Trade-off de latencia de detección:** con corridas cada 60 min en vez
de 15, un problema que recién cruza el umbral de 10 minutos puede
tardar hasta ~70 min en ser detectado y potencialmente corregido (antes
era hasta ~25 min). Aceptado a cambio del ahorro de costo — si en la
práctica resulta muy lento para algún incidente real, la frecuencia se
puede volver a subir sin tocar nada más (`OnCalendar` en
`lab-agent.timer`).
## Activación del timer (2026-08-26)
Habilitado tras confirmación explícita del propietario del lab, viendo
antes el costo mensual estimado. Unidad de usuario (`systemctl --user`,
corre como `devops`, no `root`), con límites de CPU/memoria (`CPUQuota`,
`MemoryMax`) — instalada en `/DATA/.config/systemd/user/`, no en el
`~/.config/...` de una shell interactiva.
**Gotcha encontrado y corregido en vivo:** el manager de `systemd
--user` de `devops` arranca con `HOME=/DATA` (el valor de
`/etc/passwd`), no `/DATA/AppData/home-devops` — ese override solo se
aplica al abrir una shell interactiva vía
`/etc/profile.d/zz-devops-home.sh`, que el manager de systemd nunca
carga. Sin corregirlo, `claude -p` habría buscado
`~/.claude/.credentials.json` en `/DATA/.claude/` (no existe) y la
autenticación habría fallado en cada corrida del timer, silenciosamente,
hasta la primera vez que alguien mirara el log. `lab-agent.service`
fija `Environment=HOME=/DATA/AppData/home-devops` explícitamente para
evitarlo. Se detectó y corrigió *antes* de habilitar el timer, corriendo
el servicio manualmente vía `systemctl --user start` con el entorno
real (no desde una shell interactiva) como último paso de validación.
También requirió `sudo loginctl enable-linger devops` (una vez) — sin
eso, `systemctl --user` ni siquiera conecta (no hay `/run/user/<uid>`
sin una sesión de login o linger activo).
**Pendiente, no bloqueante:**
- Opcional: `GITEA_TOKEN_FILE` para activar la señal de "runner
atascado" vía API de Gitea Actions (hoy se omite, degradación segura).
- Decidir la tensión con la ventana de `medusa-deploy` (ver arriba) —
sigue sin resolver, el timer se activó igual porque no bloquea el
resto de la lista blanca.
@@ -109,7 +109,10 @@ Un motor de búsqueda dedicado (Meilisearch, autohospedado) queda
identificado como el siguiente paso natural una vez el catálogo
crezca más allá de ~100 productos activos, pero no es parte del
alcance inmediato de Etapa 2 — se revisará cuando ese techo esté
cerca.
cerca. **Actualización 2026-08-25:** terminada la Etapa 2 (ver
[ADR 0005](0005-rediseno-storefront.md)), el disparador real resultó
ser la complejidad de facetas de la taxonomía, no el volumen de
productos — ver [ADR 0006](0006-motor-busqueda-facetas.md).
**Compatibilidad con Medusa Admin confirmada:** ninguna de las tres
opciones evaluadas, incluida la elegida, toca
@@ -1,13 +1,14 @@
# ADR 0005 — Rediseño del storefront para catálogo multi-categoría
**Estado:** propuesta — pendiente de aprobación antes de Etapa 2
**Fecha:** 2026-08-23
**Estado:** aceptada — Etapa 2 implementada y en producción
**Fecha:** 2026-08-23 (Etapa 1) — actualizado 2026-08-25 (Etapa 2 completa)
> Este documento es la propuesta de Etapa 1 del rediseño de ARI
> Shopping. No incluye código de producción — solo auditoría,
> decisión (ver también [ADR 0004](0004-evaluacion-stack-multi-categoria.md)),
> patrones de UX y datos demo ya creados y visibles en producción
> como línea base.
> Este documento arrancó como la propuesta de Etapa 1 del rediseño de
> ARI Shopping (auditoría, decisión — ver también [ADR 0004](0004-evaluacion-stack-multi-categoria.md) —
> patrones de UX y datos demo). Etapa 2 fue aprobada y se implementó
> completa en tres sub-fases (datos/categorías, navegación,
> componentes visuales) — ver la sección 6 para el detalle de cada
> sub-fase y sus PRs.
## 1. Resumen de la auditoría
@@ -255,12 +256,33 @@ objetivo es validar la tubería Admin → MinIO → NPM → frontend, no el
contenido visual final.
Usuario admin temporal `[email protected]` usado para esta
auditoría — pendiente de borrar una vez aprobada esta propuesta.
auditoría — pendiente de borrar (ver sección 6).
## 6. Próximos pasos
## 6. Etapa 2 — implementada (2026-08-23 a 2026-08-25)
Pendiente de tu aprobación explícita antes de iniciar Etapa 2. Cuando
apruebes, Etapa 2 se dividirá en sub-fases (datos/categorías primero,
luego navegación, luego componentes visuales), validando en cada paso
contra estos 9 productos demo que precio/descripción/imágenes siguen
actualizándose correctamente desde Admin.
Aprobada y ejecutada en tres sub-fases, cada una validada en vivo
contra estos 9 productos demo:
1. **Datos/categorías** (2026-08-23/24): fix del bug de Store API que
impedía ver la categoría real de un producto (causa raíz del ADR
0004); 21 categorías/9 productos demo creados; `legacyProductMeta`
eliminado (PR #29) — 17 productos legacy con categoría/colección/
marca/rango de edad reales.
2. **Navegación** (2026-08-25): navbar y tiles del home pasaron de
arrays hardcodeados a data-driven contra el árbol real de
categorías (`getCategories()`, PR #32); guard de CI actualizado
(PR #33); fix de tipos (PR #34); home/layout forzados a render
dinámico para evitar que quedaran cacheados con datos mock (PR #35).
3. **Componentes visuales** (2026-08-25), los 4 patrones de la
sección 3 más el breadcrumb/facetas de Amazon: mega-menú
"Departamentos" multi-columna (PR #36), badges antes/ahora
(PR #37), swatches de variante inline (PR #38), mensaje de
urgencia por inventario real (PR #39), breadcrumb real + conteos
por filtro en el catálogo (PR #40), tiles promocionales
intercalados en la grilla (PR #41, con fix-forward de tipos en
PR #42).
**Pendiente:** borrar el usuario admin temporal `[email protected]`
de Medusa Admin (creado para la auditoría de la sección 5, ya no
necesario). Sin próxima etapa definida todavía — este ADR no
especifica una Etapa 3.
@@ -0,0 +1,297 @@
# ADR 0006 — Motor de búsqueda y filtros facetados (Fase 5, Etapa 3)
**Estado:** aceptada — Etapa 2 en implementación (aprobada 2026-08-26 tras diagnóstico de memoria del host)
**Fecha:** 2026-08-25
## Contexto
[ADR 0004](0004-evaluacion-stack-multi-categoria.md) ya identificó un
motor de búsqueda dedicado (Meilisearch, autohospedado) como "el
siguiente paso natural" para ARI Shopping, pero lo dejó fuera del
alcance de Etapa 2 explícitamente hasta que el catálogo se acercara al
techo real de ~100 productos activos que impone
`fetchProducts(filters, limit = 100)` en `medusa.ts`.
**Ese techo todavía no llegó** — el catálogo real hoy son 26 productos
(17 legacy + 9 demo, ver [ADR 0005](0005-rediseno-storefront.md)
sección 5), lejos de 100. El disparador de esta propuesta no es el
techo de volumen sino la **complejidad de facetas**: la Etapa 2 ya
construyó una taxonomía real de 6 categorías raíz / 21 nodos
(navegación, mega-menú, breadcrumb — PRs #36-42) y un filtrado con
conteo por valor (`categoryCounts`/`brandCounts`/`ageCounts` en
`catalog-client.tsx`, PR #40) que hoy se calcula **100% client-side**
sobre los productos ya traídos — el mismo patrón de "deuda de
alcance" que el hallazgo #4 de ADR 0004 ya señaló, ahora con más
dimensiones de filtro que multiplicar. Sigue siendo tratable a 26
productos; deja de serlo mucho antes de llegar a 100 si el catálogo
crece a la vez en número de productos y en dimensiones filtrables.
Restricción no negociable, igual que en toda la Fase 5: el flujo
**Admin → evento `product.updated` → `revalidate-storefront.ts` →
`revalidateTag("medusa-products")`**, que hoy mantiene el storefront
sincronizado sin intervención manual, no se toca. Cualquier índice de
búsqueda debe sincronizarse por el mismo mecanismo de eventos, nunca
por un cron ni un paso manual.
## 1. Estado real de la integración Medusa v2 + Meilisearch
Verificado contra la documentación oficial vigente el 2026-08-25, no
contra memoria de entrenamiento:
- **Medusa no tiene un módulo de búsqueda propio integrado con
Meilisearch hoy.** La guía oficial
(`docs.medusajs.com/resources/integrations/guides/meilisearch`) es
un tutorial de módulo custom desde cero (`npm install meilisearch` +
workflows/subscribers propios) — no un paquete instalable.
- **Medusa sí introdujo un "Search Module" propio en la versión
2.19.0.** El paquete comunitario `@rokmohar/medusa-plugin-meilisearch`
(el más usado y mantenido para Meilisearch+Medusa) se conecta a ese
Search Module desde su versión `^2.0.0`, pero esa versión del plugin
**requiere Medusa `^2.19.0`**.
- **Nuestro backend corre Medusa `2.18.0`** (`commerce-backend/package.json`)
— justo por debajo de ese corte. La tabla de compatibilidad del
plugin (`github.com/rokmohar/medusa-plugin-meilisearch`) resuelve
esto: la línea `^1.4.1` soporta `Medusa >= 2.15 < 2.19`, servidor
Meilisearch `>= 1.5`, Node `>= 22`. El propio repo lo explica: *"The
2.19 release removed the search interface the v1 line was written
against, so the two lines do not overlap: v1 stops at Medusa 2.18,
v2 starts at 2.19."*
- Node de `commerce-backend` ya es `22.18.0` (Dockerfile) — cumple el
mínimo del plugin sin cambios.
**Implicación concreta:** para adoptar Meilisearch sin subir de
versión Medusa (fuera de alcance de este ADR — sería su propia
migración), la pieza a instalar es `@rokmohar/medusa-plugin-meilisearch@^1.4.1`,
no la última versión del plugin. Esto es deuda técnica documentada,
no un obstáculo: si en el futuro se sube Medusa a `2.19+`, migrar a la
línea `^2.0.0` del plugin (y al Search Module nativo) es el camino
recto.
## 2. Impacto de recursos
Estado real del host ZimaOS al momento de esta auditoría (`free -h`,
`docker stats`): 15 GiB RAM totales, **swap al 100% (5.2/5.2 GiB
usados)**, solo 351 MiB realmente libres (6.5 GiB "available" vía
cache reclamable), 4 vCPU — la misma clase de contención que ya forzó
el fix de `cpu_shares` de Gitea. Cualquier servicio nuevo en este host
es una decisión de capacidad real, no teórica.
- **Meilisearch no publica un mínimo de RAM oficial.** Su propio FAQ
(`meilisearch.com/docs/resources/help/faq`) solo dice que "más RAM
implica búsquedas más rápidas" y recomienda ~10x el tamaño del
dataset crudo en disco — sin piso duro. La cifra de "~1 GiB
cómodo" que circula en blogs de la comunidad es una estimación, no
una garantía del proveedor — se cita aquí como referencia, no como
hecho verificado.
- Meilisearch usa almacenamiento memory-mapped: el índice puede vivir
parcialmente en disco cuando no entra en RAM, degradando latencia en
vez de fallar. A la escala actual (26 productos, dataset de KB) el
uso real de RAM del índice en sí es insignificante — el costo es el
proceso base de Meilisearch, no el dataset.
- **Comparación con Typesense** (pedida explícitamente, mantenida
breve porque la diferencia relevante para este caso es clara y no
requiere más profundidad): Typesense mantiene el índice completo en
RAM (~30% más RAM que Meilisearch para el mismo dataset, según
benchmarks públicos) y, a diferencia de Meilisearch, **no tiene
ningún plugin ni integración con Medusa** — habría que escribir la
sincronización completa desde cero igual que con la guía "custom"
de Meilisearch, pero sin ninguna base comunitaria mantenida
compatible con nuestra versión de Medusa. Con Meilisearch ya
teniendo un plugin activo para `Medusa 2.18` y menor huella de RAM,
no hay motivo para evaluar Typesense más a fondo en este caso.
- **Recomendación:** desplegar Meilisearch en k3d con `requests`/`limits`
explícitos y conservadores desde el día uno (ej. `request: 256Mi/0.1
CPU`, `limit: 768Mi/0.5 CPU` — a ajustar con datos reales de uso, no
supuestos), y tratarlo como el mismo tipo de riesgo operativo que ya
se vivió con Gitea: monitorear consumo real post-despliegue antes de
darlo por estable. Dado el swap ya saturado, este es el punto de
mayor incertidumbre de toda la propuesta — más que la elección de
motor de búsqueda en sí.
## 3. Modelo de indexación
Basado en la taxonomía real ya viva en Medusa (Etapa 2) y en
`Product`/`Category` de `lib/headless/types.ts`:
| Campo | Buscable (texto, tolerante a typos) | Filtrable / faceta | Ordenable |
|---|---|---|---|
| `name` | Sí | No | No |
| `shortDescription` / `description` | Sí | No | No |
| `category` (nombre de la hoja real) | Sí | Sí (con conteo) | No |
| `collection` | Sí | Sí (con conteo) | No |
| `brand` | Sí | Sí (con conteo) | No |
| `ageRange` | No | Sí (con conteo) | No |
| `tags` | Sí | No | No |
| `price` | No | Sí (rango) | Sí |
| `compareAtPrice` | No | No | No (solo se usa para el badge, no como filtro) |
| `isNew` / `featured` | No | No | Sí (orden "Novedades"/"Destacados", igual que hoy) |
| `available` | No | Sí (booleano) | No |
| `slug`, `thumbnail`, `id` | No | No | No (solo se devuelven, no se indexan como facetas) |
La jerarquía de categorías (raíz/hija) no se indexa como un campo
aparte: el índice guarda el nombre de la hoja real tal como hoy lo
hace `product.category`, y el breadcrumb/mega-menú siguen resolviendo
la jerarquía contra `/store/product-categories` (sin cambios, ya
funciona desde la sub-fase 2). El motor de búsqueda resuelve
**búsqueda de texto + conteo de facetas**, no reemplaza la fuente de
la jerarquía.
## 4. Diseño de sincronización
Mismo patrón que `revalidate-storefront.ts`, no uno nuevo: un
subscriber adicional en `commerce-backend/src/subscribers/` (ej.
`sync-search-index.ts`) que reacciona a eventos y le pide al plugin
que reindexe el documento afectado — nunca Medusa escribiendo
directamente en Meilisearch fuera de este canal.
Eventos a escuchar, confirmados en la documentación oficial de
eventos de Medusa (`docs.medusajs.com/resources/events-reference`):
- `product.created` / `product.updated` / `product.deleted`
- `product-variant.created` / `product-variant.updated` / `product-variant.deleted`
(mismo set que ya usa `revalidate-storefront.ts`, hoy validado en
producción)
- **Nuevo respecto al subscriber actual:** `product-category.created`
/ `product-category.updated` / `product-category.deleted` — un
cambio de nombre o jerarquía de categoría debe refrescar las
facetas del índice, y hoy `revalidate-storefront.ts` no escucha
estos eventos porque el storefront los resuelve en cada request
contra `/store/product-categories` directamente, sin necesidad de
revalidación. El índice de búsqueda sí necesita enterarse.
**Punto a verificar en vivo en Etapa 2, no asumido aquí:** existe al
menos un reporte público de un evento `product-collection.updated`
que no se disparaba de forma confiable en ciertas versiones de Medusa
(`github.com/medusajs/medusa/issues/11197`). Antes de dar por cerrado
el diseño de sincronización hay que repetir exactamente la validación
que ya se hizo para `revalidate-storefront.ts` en la auditoría de
Etapa 1 de Fase 5: crear/editar una categoría real vía Admin y
confirmar en vivo que el evento llega al subscriber, no confiar en que
la documentación coincide con el comportamiento real de `2.18.0`
(el mismo tipo de brecha que ya se encontró una vez con la relación
`+categories` de `/store/products`).
## 5. Diseño de UI — filtros facetados y búsqueda
**Filtros facetados (sidebar de `/catalog`):** mismo layout que ya
construyó la sub-fase 3 (`catalog-client.tsx`, PR #40) — sidebar con
"Categoría"/"Marca"/"Edad" como pills con conteo entre paréntesis y
slider de precio máximo. Lo único que cambia es **de dónde sale el
conteo**: hoy `categoryCounts`/`brandCounts`/`ageCounts` se calculan
recorriendo el arreglo de productos ya traído en el cliente; con el
motor de búsqueda, esos conteos vienen del propio Meilisearch
(`facetDistribution`) en la misma respuesta que trae los productos
filtrados, sin recorrer nada en el navegador. El breadcrumb no cambia
— sigue resolviéndose contra la jerarquía real de categorías, no
contra el índice de búsqueda.
```
Inicio > Mochilas y morrales > Marvel breadcrumb real (sin cambios)
┌────────────┬──────────────────────────────────────┐
│ Categoría │ [buscador con autocompletado] │ ← nuevo
│ ☐ Marvel(6)│ [card][card][card] │
│ ☐ Frozen(4)│ [card][card][card] │
│ Marca │ │
│ ☐ Disney(2)│ conteos ahora vienen de Meilisearch, │
│ Precio │ no de recorrer el arreglo en cliente │
│ [====○===] │ │
└────────────┴──────────────────────────────────────┘
```
**Búsqueda con autocompletado (barra del navbar):** hoy
`PredictiveSearch` (`components/search/predictive-search.tsx`) filtra
por substring sobre los productos ya cargados en memoria del cliente —
funciona a 26 productos, no escala y no tolera errores de tipeo (ADR
0004, hallazgo #5). Se reemplaza la fuente de datos del mismo
componente visual por `searchProducts(query)` del `CatalogProvider`
(interfaz que **ya existe** en `lib/headless/types.ts`, sin necesidad
de un tipo nuevo) apuntando a Meilisearch en vez de al `?q=` de
Medusa: tolerancia a typos y ranking por relevancia nativos del motor,
mismo contrato de datos que ya consume el dropdown de resultados —
cero cambio de componente React, solo de qué hay detrás de
`CatalogProvider.searchProducts`.
## Opciones consideradas
| Opción | A favor | En contra |
|---|---|---|
| **Meilisearch autohospedado** (`@rokmohar/medusa-plugin-meilisearch@^1.4.1`, sincronizado por eventos) | Plugin comunitario activo y compatible con nuestra versión exacta de Medusa (2.18); menor huella de RAM que la alternativa evaluada; almacenamiento memory-mapped se degrada en vez de fallar bajo presión de memoria; mismo patrón de subscriber que `revalidate-storefront.ts`, cero riesgo para ese flujo | Ningún paquete "oficial" de Medusa (el plugin es comunitario, no del core); atado a la línea `v1` del plugin mientras no se suba Medusa a 2.19+; un servicio más para operar en un host ya con swap saturado |
| **Typesense autohospedado** | Autocompletado ~2x más rápido según benchmarks públicos | Cero integración con Medusa (habría que escribir la sincronización completa desde cero, igual esfuerzo que la guía "custom" de Meilisearch pero sin base comunitaria); ~30% más RAM que Meilisearch para el mismo dataset, el peor trade-off posible dado el estado actual del host |
| **Módulo custom desde cero** (seguir la guía oficial de Medusa, sin el plugin de rokmohar) | Sin dependencia de un paquete de terceros; control total | Reimplementa lo que el plugin comunitario ya resuelve (declaración de índices, reindexado atómico, tracking de escrituras); más superficie propia para mantener sin ganancia clara sobre usar el plugin ya compatible con 2.18 |
| **No adoptar motor de búsqueda todavía** (seguir con filtrado 100% client-side) | Cero riesgo, cero componente nuevo | No resuelve la deuda de facetas ya identificada en ADR 0004 y agravada por la sub-fase 3; el techo de complejidad de filtros llega antes que el de 100 productos si el catálogo sigue creciendo en categorías |
## Decisión
**Meilisearch autohospedado**, vía `@rokmohar/medusa-plugin-meilisearch@^1.4.1`
(no la última versión del plugin — esa exige Medusa 2.19+, fuera de
alcance), sincronizado por el mismo patrón de subscriber por eventos
que ya usa `revalidate-storefront.ts`, con `product-category.*` sumado
al set de eventos existente. Se despliega en k3d con límites de
recursos conservadores desde el primer día y se trata como
experimento a monitorear, no como capacidad garantizada, dado el
estado actual de memoria del host.
**No se sube la versión de Medusa como parte de esta decisión** —
queda como deuda técnica documentada para cuando se justifique
migrar a la línea `v2` del plugin y al Search Module nativo de
Medusa.
## Consecuencias
- Se gana: conteo de facetas real (no client-side), búsqueda tolerante
a errores de tipeo con ranking por relevancia, y una arquitectura
que ya no depende del techo de 100 productos de `fetchProducts`.
- Se acepta como deuda técnica: quedar atado a la línea `v1` del
plugin de Meilisearch mientras Medusa siga en `2.18.x`; migrar a
Medusa `2.19+` es un proyecto aparte, no parte de esta Etapa 3.
- Riesgo operativo real y no resuelto todavía por este ADR: el host ya
corre con el swap saturado — el despliegue de Etapa 2 debe empezar
con límites de recursos conservadores y validación en vivo de
consumo real antes de confiar en el servicio, siguiendo la misma
lección que dejó el incidente de recursos de Gitea.
- Revertir esta decisión implica desinstalar el plugin y el
subscriber nuevo, y volver el buscador/filtrado a 100% client-side
— no hay migración de datos que deshacer, porque Meilisearch nunca
es la fuente de verdad, solo una copia de solo lectura indexada
desde Medusa.
## Nota de implementación (2026-08-26, antes de escribir código de Etapa 2)
Antes de tocar código se verificó el código fuente real de
`@rokmohar/medusa-plugin-meilisearch` en el tag exacto `v1.4.1`
(`github.com/rokmohar/medusa-plugin-meilisearch`), no su README
genérico ni memoria de entrenamiento — misma disciplina que ya destapó
el bug de `+categories` en `/store/products`. Aparecieron tres
desviaciones reales respecto al diseño de §4/§5 de este ADR:
1. **No se escribe un subscriber nuevo.** El plugin trae sus propios
subscribers (`meilisearch-product-upsert.ts`,
`meilisearch-category-upsert.ts`, variantes/colección/inventario),
auto-registrados al instalar el paquete — el propio README dice
*"You don't need custom subscribers anymore, you can remove them."*
Escribir `sync-search-index.ts` como describe §4 causaría doble
indexado. La escucha de `product-category.*` que pedía §4 ya viene
incluida en los subscribers del plugin.
2. **Las rutas Store API del plugin no devuelven `facetDistribution`**,
pese a que §5 asume que sí. El servicio interno
(`MeiliSearchService.search()`) sí puede pedirlo vía
`additionalOptions: { facets: [...] }`, pero las rutas
empaquetadas nunca lo piden ni lo reenvían. Se agrega una ruta
Store API propia (`src/api/store/search/route.ts`) que resuelve el
servicio del plugin desde el container y arma
`{ products, facetDistribution }`. Consecuencia positiva no
buscada: Meilisearch queda 100% `ClusterIP`-only, sin Ingress, sin
key pública ni ruta de red navegador→Meilisearch — todo pasa por
Medusa.
3. **El plugin se conecta vía un array `plugins:` nuevo en
`medusa-config.ts`**, no otra entrada en `modules:` (el único
precedente en este repo, el file-provider S3, usa `modules:`), y
usa nombres de env var propios (`MEILISEARCH_HOST`/
`MEILISEARCH_API_KEY`, leídos por el plugin) distintos del env var
del servidor Meilisearch en sí (`MEILI_MASTER_KEY`, leído por el
contenedor `getmeili/meilisearch`) — mismo valor, dos nombres de
secret.
Ninguna de las tres cambia la decisión ni sus consecuencias — cambian
el cómo, no el qué. Plan de implementación completo, fase por fase,
en la sesión que aprobó esta Etapa 2 (2026-08-26).
+2
View File
@@ -84,6 +84,7 @@ nav:
- Visión general: arquitectura/vision-general.md
- Red y exposición: arquitectura/red-y-exposicion.md
- Flujo GitOps: arquitectura/gitops-flujo.md
- Agente de monitoreo: arquitectura/agente-monitoreo.md
- Glosario: arquitectura/glosario.md
- Decisiones (ADRs):
- "0001 · Por qué k3d": decisiones/0001-por-que-k3d.md
@@ -91,6 +92,7 @@ nav:
- "0003 · HTTP/2 sobre QUIC": decisiones/0003-por-que-http2-sobre-quic-tunnel.md
- "0004 · Stack multi-categoría": decisiones/0004-evaluacion-stack-multi-categoria.md
- "0005 · Rediseño storefront": decisiones/0005-rediseno-storefront.md
- "0006 · Motor de búsqueda y facetas": decisiones/0006-motor-busqueda-facetas.md
- Plantilla ADR: decisiones/plantilla-adr.md
- Playbooks:
- Crashloop Medusa: playbooks/incidente-crashloop-medusa.md
@@ -25,7 +25,7 @@ spec:
runAsNonRoot: false
containers:
- name: app
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.125
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.162
# 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
+19 -4
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { CatalogClient } from "@/components/catalog/catalog-client";
import { getProducts } from "@/lib/headless/client";
import { getCategories, getProducts } from "@/lib/headless/client";
export const metadata: Metadata = {
title: "Catálogo",
@@ -17,17 +17,32 @@ function single(value: string | string[] | undefined): string {
export default async function CatalogPage({ searchParams }: CatalogPageProps) {
const params = await searchParams;
const products = await getProducts();
const [products, categories] = await Promise.all([getProducts(), getCategories()]);
const category = single(params.category) || "Todos";
const collection = single(params.collection);
const query = single(params.q);
const section = single(params.section);
// Los links de navbar/tiles apuntan a categorias raiz (ej. "Mochilas y
// morrales"), pero ningun producto tiene esa categoria asignada
// directamente -- solo sus hijos ("Marvel", "Frozen", etc.). Este mapa
// expande cada raiz-con-hijos al nombre real de sus hijos para que el
// filtro siga funcionando por igualdad exacta de `product.category`.
const categoryGroups: Record<string, string[]> = {};
for (const node of categories) {
if (node.parentId) continue;
const children = categories.filter((child) => child.parentId === node.id);
if (children.length > 0) {
categoryGroups[node.name] = children.map((child) => child.name);
}
}
return (
<CatalogClient
key={`${category}:${collection}:${query}:${section}`}
key={`${category}:${collection}:${query}`}
products={products}
categories={categories}
categoryGroups={categoryGroups}
initialCategory={category}
initialCollection={collection}
initialQuery={query}
+10 -3
View File
@@ -4,7 +4,14 @@ import { CartDrawer } from "@/components/cart/cart-drawer";
import { Footer } from "@/components/layout/footer";
import { Navbar } from "@/components/layout/navbar";
import { WishlistDrawer } from "@/components/wishlist/wishlist-drawer";
import { getProducts } from "@/lib/headless/client";
import { getCategories, getProducts } from "@/lib/headless/client";
// El Navbar (categorias/colecciones/predictive search) vive en este layout
// compartido -- si alguna pagina futura queda estatica, este fetch se
// congelaria con datos de build time (mock) igual que le paso al home
// (ver app/page.tsx). Forzarlo aca protege el nav en cualquier ruta, no
// solo en las que hoy ya son dinamicas por otro motivo (searchParams, etc.).
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://shop.cruzcloud.net"),
@@ -16,6 +23,6 @@ export const metadata: Metadata = {
export const viewport: Viewport = { width: "device-width", initialScale: 1, themeColor: "#0f172a" };
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const products = await getProducts();
return <html lang="es"><body><Navbar products={products} /><main>{children}</main><Footer /><CartDrawer /><WishlistDrawer /></body></html>;
const [products, categories] = await Promise.all([getProducts(), getCategories()]);
return <html lang="es"><body><Navbar products={products} categories={categories} /><main>{children}</main><Footer /><CartDrawer /><WishlistDrawer /></body></html>;
}
+10 -3
View File
@@ -5,10 +5,17 @@ import { HeroBento } from "@/components/home/hero-bento";
import { TrustBar } from "@/components/home/trust-bar";
import { ProductCarousel } from "@/components/product/product-carousel";
import { Button } from "@/components/ui/button";
import { getProducts } from "@/lib/headless/client";
import { getCategories, getProducts } from "@/lib/headless/client";
// Sin esto, Next.js prerenderiza "/" en build time dentro del Dockerfile,
// donde HEADLESS_PROVIDER/MEDUSA_* todavia no existen (solo se inyectan en
// runtime via el Deployment) -- cae al provider mock y ese HTML queda
// cacheado 1 año (visto en vivo: x-nextjs-prerender:1, s-maxage=31536000),
// violando el flujo Admin -> revalidate del ADR 0005 justo en el home.
export const dynamic = "force-dynamic";
export default async function HomePage() {
const products = await getProducts();
const [products, categories] = await Promise.all([getProducts(), getCategories()]);
const treasures = products.filter((product) => product.isNew || product.featured).slice(0, 8);
return <><HeroBento /><TrustBar /><div className="content-auto"><CategoryTiles /></div><section className="content-auto mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="content-auto mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-gradient-to-r from-cyan-500 via-blue-600 to-violet-600 text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-yellow-300">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div><div className="min-h-64 bg-[url('/images/promo/promo-backpacks.webp')] bg-cover bg-center" /></div></section></>;
return <><HeroBento /><TrustBar /><div className="content-auto"><CategoryTiles categories={categories} /></div><section className="content-auto mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="content-auto mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-gradient-to-r from-cyan-500 via-blue-600 to-violet-600 text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-yellow-300">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div><div className="min-h-64 bg-[url('/images/promo/promo-backpacks.webp')] bg-cover bg-center" /></div></section></>;
}
@@ -4,6 +4,7 @@ metadata:
name: commerce-ingress
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
traefik.ingress.kubernetes.io/router.middlewares: ecommerce-commerce-redirect-app@kubernetescrd
spec:
ingressClassName: traefik
rules:
@@ -9,3 +9,4 @@ resources:
- minio-bootstrap-job.yaml
- medusa/
- ingress-commerce.yaml
- middleware-commerce-redirect.yaml
@@ -0,0 +1,16 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: commerce-redirect-app
spec:
# Redirige solo la raíz exacta de commerce.cruzcloud.net a /app (admin de
# Medusa) — el regex incluye el host a propósito: esta Middleware se
# referencia desde el mismo Ingress que también enruta media.cruzcloud.net,
# y sin el host en el regex un request a la raíz de ese otro dominio
# también matchearía (ambas rutas comparten Ingress -> mismas
# router.middlewares). Si el regex no matchea, Traefik deja pasar el
# request sin modificarlo.
redirectRegex:
regex: "^https?://commerce\\.cruzcloud\\.net/$"
replacement: "https://commerce.cruzcloud.net/app"
permanent: true
@@ -1,21 +1,71 @@
"use client";
import { SlidersHorizontal, X } from "lucide-react";
import Link from "next/link";
import { ChevronRight, SlidersHorizontal, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { PromoTile } from "@/components/home/category-tiles";
import { ProductCard } from "@/components/product/product-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { Product } from "@/lib/headless/types";
import type { Category, Product } from "@/lib/headless/types";
// Cada cuantas tarjetas de producto se intercala un tile promocional en
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
// promocionales intercalados, no solo tarjetas homogeneas.
const PROMO_TILE_INTERVAL = 8;
type GridItem = { type: "product"; product: Product } | { type: "promo"; category: Category };
type CatalogClientProps = {
products: Product[];
categories?: Category[];
categoryGroups?: Record<string, string[]>;
initialCategory?: string;
initialCollection?: string;
initialQuery?: string;
};
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());
}
function matchesCategory(product: Product, allowedCategories: string[]): boolean {
return allowedCategories.includes(product.category);
}
function matchesCollection(product: Product, collection: string): boolean {
return !collection || product.collection === collection;
}
function matchesBrand(product: Product, brand: string): boolean {
return brand === "Todas" || product.brand === brand;
}
function matchesAge(product: Product, age: string): boolean {
return age === "Todas" || product.ageRange === age;
}
function matchesPrice(product: Product, maxPrice: number): boolean {
return product.price === null || maxPrice === 0 || product.price <= maxPrice;
}
// Camina parentId hacia arriba desde la categoria seleccionada para armar
// el breadcrumb real (Inicio > raiz > hija), igual que en Amazon.
function buildBreadcrumb(categories: Category[], name: string): Category[] {
const node = categories.find((category) => category.name === name);
if (!node) return [];
const trail = [node];
let current = node;
while (current.parentId) {
const parent = categories.find((category) => category.id === current.parentId);
if (!parent) break;
trail.unshift(parent);
current = parent;
}
return trail;
}
export function CatalogClient({
products,
categories: categoryNodes = [],
categoryGroups = {},
initialCategory = "Todos",
initialCollection = "",
initialQuery = "",
@@ -48,28 +98,30 @@ export function CatalogClient({
setMaxPrice(maxCatalogPrice);
}, [initialCategory, initialCollection, initialQuery, maxCatalogPrice]);
// 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 breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]);
// No promociona la misma categoria raiz que el usuario ya esta viendo.
const promoRoots = useMemo(() => {
const excludeRootId = breadcrumb[0]?.id;
return categoryNodes.filter((node) => !node.parentId && node.id !== excludeRootId);
}, [categoryNodes, breadcrumb]);
const filtered = useMemo(
() =>
products
.filter((product) => {
const search = [
product.name,
product.category,
product.collection,
product.brand,
]
.join(" ")
.toLowerCase();
return (
(!query || search.includes(query.toLowerCase())) &&
(category === "Todos" || product.category === category) &&
(!collection || product.collection === collection) &&
(brand === "Todas" || product.brand === brand) &&
(age === "Todas" || product.ageRange === age) &&
(product.price === null || maxPrice === 0 || product.price <= maxPrice)
);
})
.filter(
(product) =>
matchesQuery(product, query) &&
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) &&
matchesBrand(product, brand) &&
matchesAge(product, age) &&
matchesPrice(product, maxPrice),
)
.sort((a, b) =>
sort === "name"
? a.name.localeCompare(b.name, "es")
@@ -77,9 +129,81 @@ export function CatalogClient({
? Number(b.isNew) - Number(a.isNew)
: Number(b.featured) - Number(a.featured),
),
[age, brand, category, collection, maxPrice, products, query, sort],
[age, allowedCategories, brand, category, collection, maxPrice, products, query, sort],
);
// Intercala un tile promocional cada PROMO_TILE_INTERVAL productos,
// ciclando por las categorias raiz -- si no hay jerarquia de
// categorias disponible, la grilla queda igual que antes (solo
// productos).
const gridItems = useMemo<GridItem[]>(() => {
if (promoRoots.length === 0) return filtered.map((product) => ({ type: "product", product }));
const items: GridItem[] = [];
let promoIndex = 0;
filtered.forEach((product, index) => {
items.push({ type: "product", product });
const isLast = index === filtered.length - 1;
const promoCategory = promoRoots[promoIndex % promoRoots.length];
if (!isLast && (index + 1) % PROMO_TILE_INTERVAL === 0 && promoCategory) {
items.push({ type: "promo", category: promoCategory });
promoIndex += 1;
}
});
return items;
}, [filtered, promoRoots]);
// Conteos por valor de filtro (patron Amazon: "Marca (12)"), calculados
// sobre los productos que ya pasan el resto de filtros activos -- cada
// dimension se cuenta excluyendose a si misma para reflejar cuantos
// resultados habria si se eligiera ese valor.
const categoryCounts = useMemo(() => {
const base = products.filter(
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice),
);
const counts: Record<string, number> = { Todos: base.length };
for (const name of categories) {
if (name === "Todos") continue;
const allowed = categoryGroups[name] ?? [name];
counts[name] = base.filter((product) => matchesCategory(product, allowed)).length;
}
return counts;
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
const brandCounts = useMemo(() => {
const base = products.filter(
(product) =>
matchesQuery(product, query) &&
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) &&
matchesAge(product, age) &&
matchesPrice(product, maxPrice),
);
const counts: Record<string, number> = { Todas: base.length };
for (const value of brands) {
if (value === "Todas") continue;
counts[value] = base.filter((product) => product.brand === value).length;
}
return counts;
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
const ageCounts = useMemo(() => {
const base = products.filter(
(product) =>
matchesQuery(product, query) &&
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) &&
matchesBrand(product, brand) &&
matchesPrice(product, maxPrice),
);
const counts: Record<string, number> = { Todas: base.length };
for (const value of ages) {
if (value === "Todas") continue;
counts[value] = base.filter((product) => product.ageRange === value).length;
}
return counts;
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]);
const reset = () => {
setQuery("");
setCategory("Todos");
@@ -93,6 +217,27 @@ export function CatalogClient({
return (
<div className="mx-auto max-w-7xl px-4 py-10">
<div className="mb-8">
{breadcrumb.length > 0 && (
<nav aria-label="Breadcrumb" className="mb-4 flex flex-wrap items-center gap-1.5 text-xs font-bold text-slate-500">
<Link href="/catalog" prefetch={false} className="hover:text-fuchsia-600">
Inicio
</Link>
{breadcrumb.map((node, index) => (
<span key={node.id} className="flex items-center gap-1.5">
<ChevronRight className="size-3" />
{index === breadcrumb.length - 1 ? (
<span aria-current="page" className="text-slate-900">
{node.name}
</span>
) : (
<Link href={`/catalog?category=${encodeURIComponent(node.name)}`} prefetch={false} className="hover:text-fuchsia-600">
{node.name}
</Link>
)}
</span>
))}
</nav>
)}
<span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">
Catálogo completo
</span>
@@ -126,9 +271,10 @@ export function CatalogClient({
values={categories}
selected={category}
onChange={setCategory}
counts={categoryCounts}
/>
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} />
<Filter title="Edad" values={ages} selected={age} onChange={setAge} />
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} />
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />
<fieldset>
<legend className="mb-3 text-sm font-black">Precio máximo</legend>
@@ -172,9 +318,13 @@ export function CatalogClient({
{filtered.length ? (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((product) => (
<ProductCard key={product.id} product={product} />
))}
{gridItems.map((item, index) =>
item.type === "product" ? (
<ProductCard key={item.product.id} product={item.product} />
) : (
<PromoTile key={`promo-${index}`} category={item.category} className="min-h-[22rem]" />
),
)}
</div>
) : (
<div className="grid min-h-80 place-items-center rounded-3xl border border-dashed border-slate-300 text-center">
@@ -197,11 +347,13 @@ function Filter({
values,
selected,
onChange,
counts,
}: {
title: string;
values: string[];
selected: string;
onChange: (value: string) => void;
counts?: Record<string, number>;
}) {
return (
<fieldset>
@@ -219,6 +371,7 @@ function Filter({
}`}
>
{value}
{counts && <span className="ml-1 opacity-60">({counts[value] ?? 0})</span>}
</button>
))}
</div>
@@ -1,14 +1,76 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
import type { Category } from "@/lib/headless/types";
import { cn } from "@/lib/utils";
const categories = [
{ title: "Juguetes", subtitle: "Personajes que inspiran", image: "/images/categories/figuras.webp", href: "/catalog?category=Figuras", color: "from-yellow-300 to-orange-400" },
{ title: "Peluches", subtitle: "Compañeros para regalar", image: "/images/categories/peluches.webp", href: "/catalog?category=Peluches", color: "from-pink-400 to-fuchsia-500" },
{ title: "Mochilas", subtitle: "Estilo para cada aventura", image: "/images/categories/mochilas.webp", href: "/catalog?category=Mochilas", color: "from-cyan-400 to-blue-500" },
{ title: "Marvel", subtitle: "Tu héroe favorito", image: "/images/categories/marvel.webp", href: "/catalog?collection=Marvel", color: "from-red-500 to-rose-700" },
];
type TileContent = { subtitle: string; image?: string; color: string };
export function CategoryTiles() {
return <section className="mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-4"><div><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Explora por mundos</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Encuentra tu próximo tesoro</h2></div><Link href="/catalog" className="hidden font-black text-cyan-700 sm:block">Ver todo →</Link></div><div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">{categories.map((item) => <Link key={item.title} href={item.href} className={`group relative min-h-80 overflow-hidden rounded-[2rem] bg-gradient-to-br ${item.color} shadow-xl`}><Image src={item.image} alt={item.title} fill sizes="(max-width: 640px) 100vw, 25vw" className="object-cover transition duration-500 group-hover:scale-105" /><div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-white/5" /><div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-6 text-white"><div><h3 className="text-2xl font-black">{item.title}</h3><p className="mt-1 text-sm font-semibold text-white/80">{item.subtitle}</p></div><span className="grid size-11 place-items-center rounded-full bg-white text-slate-950 transition group-hover:rotate-45"><ArrowUpRight /></span></div></Link>)}</div></section>;
// No hay fotografia real todavia para las 6 categorias raiz de la taxonomia
// (ver ADR 0005) -- solo se mapean imagen/subtitulo para las que ya tienen
// un asset en public/images/categories/. Las demas caen al tile de solo
// gradiente en vez de mostrar una imagen que no les corresponde.
const tileContent: Record<string, TileContent> = {
juguetes: { subtitle: "Personajes que inspiran", image: "/images/categories/figuras.webp", color: "from-yellow-300 to-orange-400" },
"mochilas-y-morrales": { subtitle: "Estilo para cada aventura", image: "/images/categories/mochilas.webp", color: "from-cyan-400 to-blue-500" },
"bebes-y-primera-infancia": { subtitle: "Primeros descubrimientos", color: "from-pink-300 to-fuchsia-400" },
educativos: { subtitle: "Aprender jugando", color: "from-emerald-400 to-teal-600" },
"accion-y-aventura": { subtitle: "Para explorar sin límites", color: "from-red-500 to-rose-700" },
variedades: { subtitle: "Un poco de todo", color: "from-violet-400 to-purple-600" },
};
const defaultTile: TileContent = { subtitle: "Explora esta categoría", color: "from-slate-400 to-slate-600" };
// Tile individual reutilizado tanto por la seccion de mundos del home
// como por el grid del catalogo (tiles promocionales intercalados,
// ADR 0005 seccion 3, patron Walmart) -- mismo primitivo visual, no un
// componente nuevo.
export function PromoTile({ category, className = "" }: { category: Category; className?: string }) {
const content = tileContent[category.handle] ?? defaultTile;
return (
<Link
href={`/catalog?category=${encodeURIComponent(category.name)}`}
className={cn(`group relative min-h-64 overflow-hidden rounded-[2rem] bg-gradient-to-br ${content.color} shadow-xl`, className)}
>
{content.image && (
<>
<Image src={content.image} alt={category.name} fill sizes="(max-width: 640px) 100vw, (max-width: 1024px) 33vw, 16vw" className="object-cover transition duration-500 group-hover:scale-105" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-white/5" />
</>
)}
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-6 text-white">
<div>
<h3 className="text-xl font-black">{category.name}</h3>
<p className="mt-1 text-sm font-semibold text-white/80">{content.subtitle}</p>
</div>
<span className="grid size-11 shrink-0 place-items-center rounded-full bg-white text-slate-950 transition group-hover:rotate-45">
<ArrowUpRight />
</span>
</div>
</Link>
);
}
export function CategoryTiles({ categories }: { categories: Category[] }) {
const roots = categories.filter((category) => !category.parentId);
if (roots.length === 0) return null;
return (
<section className="mx-auto max-w-7xl px-4 py-14">
<div className="mb-8 flex items-end justify-between gap-4">
<div>
<span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Explora por mundos</span>
<h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Encuentra tu próximo tesoro</h2>
</div>
<Link href="/catalog" className="hidden font-black text-cyan-700 sm:block">Ver todo →</Link>
</div>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{roots.map((category) => (
<PromoTile key={category.id} category={category} />
))}
</div>
</section>
);
}
+224 -148
View File
@@ -3,42 +3,28 @@
import Image from "next/image";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Heart, Menu, MessageCircle, PackageCheck, ShoppingCart, UserRound } from "lucide-react";
import { Suspense, useState } from "react";
import { ChevronDown, Heart, LayoutGrid, Menu, MessageCircle, PackageCheck, ShoppingCart, UserRound } from "lucide-react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { PredictiveSearch } from "@/components/search/predictive-search";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import type { Product } from "@/lib/headless/types";
import type { Category, Product } from "@/lib/headless/types";
import { useCartStore } from "@/store/cart-store";
import { useWishlistStore } from "@/store/wishlist-store";
type CategoryLink = {
label: string;
href: string;
category?: string;
collection?: string;
section?: string;
root?: boolean;
};
type CategoryNode = Category & { children: Category[] };
const categories: CategoryLink[] = [
{ label: "Juguetes para bebés", href: "/catalog?category=Figuras", category: "Figuras" },
{
label: "Educativos",
href: "/catalog?category=Figuras&section=educativos",
category: "Figuras",
section: "educativos",
},
{ label: "Muñecas y peluches", href: "/catalog?category=Peluches", category: "Peluches" },
{ label: "Acción y aventura", href: "/catalog?collection=Marvel", collection: "Marvel" },
{ label: "Mochilas", href: "/catalog?category=Mochilas", category: "Mochilas" },
{ label: "Variedades", href: "/catalog", root: true },
];
function buildCategoryTree(categories: Category[]): CategoryNode[] {
return categories
.filter((category) => !category.parentId)
.map((root) => ({
...root,
children: categories.filter((category) => category.parentId === root.id),
}));
}
const collections = ["Toy Story", "Marvel", "Intensamente", "Lilo & Stitch", "Frozen"];
export function Navbar({ products }: { products: Product[] }) {
export function Navbar({ products, categories }: { products: Product[]; categories: Category[] }) {
const [menuOpen, setMenuOpen] = useState(false);
const [accountOpen, setAccountOpen] = useState(false);
const items = useCartStore((state) => state.items);
@@ -50,6 +36,14 @@ export function Navbar({ products }: { products: Product[] }) {
const count = cartHydrated ? items.reduce((sum, item) => sum + item.quantity, 0) : 0;
const wishlistCount = wishlistHydrated ? wishlistProducts.length : 0;
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.
const collections = useMemo(
() => [...new Set(products.map((product) => product.collection).filter(Boolean))],
[products],
);
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-gradient-to-r from-amber-300 via-yellow-200 to-lime-200 px-4 py-2 text-center text-xs font-black text-slate-950 sm:text-sm">
@@ -77,8 +71,8 @@ export function Navbar({ products }: { products: Product[] }) {
</div>
</div>
<Suspense fallback={<DesktopCategoryNavigationFallback onOpenMenu={() => setMenuOpen(true)} />}>
<DesktopCategoryNavigation onOpenMenu={() => setMenuOpen(true)} />
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} onOpenMenu={() => setMenuOpen(true)} />}>
<DesktopCategoryNavigation tree={tree} onOpenMenu={() => setMenuOpen(true)} />
</Suspense>
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
@@ -90,14 +84,18 @@ export function Navbar({ products }: { products: Product[] }) {
<div className="flex-1 overflow-y-auto p-6">
<p className="mb-3 text-xs font-black uppercase tracking-[.16em] text-fuchsia-600">Categorías</p>
<div className="grid gap-2">
<Suspense fallback={<MobileCategoryLinksFallback onNavigate={() => setMenuOpen(false)} />}>
<MobileCategoryLinks onNavigate={() => setMenuOpen(false)} />
<Suspense fallback={<MobileCategoryLinksFallback tree={tree} onNavigate={() => setMenuOpen(false)} />}>
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
</Suspense>
</div>
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-violet-600">Colecciones</p>
<div className="flex flex-wrap gap-2">
{collections.map((collection) => <Link key={collection} href={`/catalog?collection=${encodeURIComponent(collection)}`} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-4 py-2 text-sm font-bold hover:bg-violet-100 hover:text-violet-700">{collection}</Link>)}
</div>
{collections.length > 0 && (
<>
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-violet-600">Colecciones</p>
<div className="flex flex-wrap gap-2">
{collections.map((collection) => <Link key={collection} href={`/catalog?collection=${encodeURIComponent(collection)}`} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-4 py-2 text-sm font-bold hover:bg-violet-100 hover:text-violet-700">{collection}</Link>)}
</div>
</>
)}
<div className="mt-8 grid grid-cols-2 gap-3 sm:hidden">
<Button variant="outline" onClick={() => { setMenuOpen(false); setAccountOpen(true); }}><UserRound className="size-4" />Cuenta</Button>
<Button variant="outline" onClick={() => { setMenuOpen(false); openWishlist(); }}><Heart className="size-4" />Favoritos</Button>
@@ -127,140 +125,218 @@ function useCategoryNavigationState() {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentCategory = searchParams.get("category") ?? "";
const currentCollection = searchParams.get("collection") ?? "";
const currentSection = searchParams.get("section") ?? "";
const isCatalog = pathname === "/catalog";
const isCategoryActive = (item: CategoryLink) => {
if (!isCatalog) return false;
if (item.root) {
return !currentCategory && !currentCollection && !currentSection;
}
return (
(item.category ?? "") === currentCategory &&
(item.collection ?? "") === currentCollection &&
(item.section ?? "") === currentSection
);
return {
isAllActive: isCatalog && !currentCategory,
isActive: (name: string) => isCatalog && currentCategory === name,
};
return { isCategoryActive };
}
function DesktopCategoryNavigation({ onOpenMenu }: { onOpenMenu: () => void }) {
const { isCategoryActive } = useCategoryNavigationState();
function pillClass(active: boolean): string {
return `shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${
active
? "bg-gradient-to-r from-cyan-500 to-violet-500 text-white shadow-lg shadow-violet-950/30"
: "text-white/90 hover:bg-gradient-to-r hover:from-cyan-500 hover:to-violet-500 hover:text-white"
}`;
}
function mobileLinkClass(active: boolean): string {
return `rounded-2xl border px-4 py-3 font-extrabold transition ${
active
? "border-violet-500 bg-violet-50 text-violet-700"
: "border-slate-200 hover:border-cyan-400 hover:bg-cyan-50"
}`;
}
function DesktopCategoryNavigation({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
const { isActive, isAllActive } = useCategoryNavigationState();
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={isActive} isAllActive={isAllActive} />;
}
function DesktopCategoryNavigationFallback({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={() => false} isAllActive={false} />;
}
function CategoryNavBar({
tree,
onOpenMenu,
isActive,
isAllActive,
}: {
tree: CategoryNode[];
onOpenMenu: () => void;
isActive: (name: string) => boolean;
isAllActive: boolean;
}) {
return (
<nav className="border-b-4 border-fuchsia-500 bg-slate-950 text-white">
<div className="mx-auto flex max-w-7xl items-center gap-1 overflow-x-auto px-4 py-2 [scrollbar-width:none]">
<div className="mx-auto flex max-w-7xl items-center gap-1 px-4 py-2">
<Button
variant="ghost"
size="icon"
onClick={onOpenMenu}
aria-label="Abrir menú principal"
className="mr-3 shrink-0 text-white hover:bg-white/10"
className="mr-2 shrink-0 text-white hover:bg-white/10"
>
<Menu />
</Button>
{categories.map((category) => {
const active = isCategoryActive(category);
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
return (
<Link
key={category.label}
href={category.href}
prefetch={false}
aria-current={active ? "page" : undefined}
className={`shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${
active
? "bg-gradient-to-r from-cyan-500 to-violet-500 text-white shadow-lg shadow-violet-950/30"
: "text-white/90 hover:bg-gradient-to-r hover:from-cyan-500 hover:to-violet-500 hover:text-white"
}`}
>
{category.label}
</Link>
);
})}
</div>
</nav>
);
}
function DesktopCategoryNavigationFallback({ onOpenMenu }: { onOpenMenu: () => void }) {
return (
<nav className="border-b-4 border-fuchsia-500 bg-slate-950 text-white">
<div className="mx-auto flex max-w-7xl items-center gap-1 overflow-x-auto px-4 py-2 [scrollbar-width:none]">
<Button
variant="ghost"
size="icon"
onClick={onOpenMenu}
aria-label="Abrir menú principal"
className="mr-3 shrink-0 text-white hover:bg-white/10"
>
<Menu />
</Button>
{categories.map((category) => (
<Link
key={category.label}
href={category.href}
prefetch={false}
className="shrink-0 rounded-full px-4 py-2 text-sm font-extrabold text-white/90 transition hover:bg-gradient-to-r hover:from-cyan-500 hover:to-violet-500 hover:text-white"
>
{category.label}
</Link>
))}
</div>
</nav>
);
}
function MobileCategoryLinks({ onNavigate }: { onNavigate: () => void }) {
const { isCategoryActive } = useCategoryNavigationState();
return (
<>
{categories.map((category) => {
const active = isCategoryActive(category);
return (
<Link
key={category.label}
href={category.href}
prefetch={false}
aria-current={active ? "page" : undefined}
onClick={onNavigate}
className={`rounded-2xl border px-4 py-3 font-extrabold transition ${
active
? "border-violet-500 bg-violet-50 text-violet-700"
: "border-slate-200 hover:border-cyan-400 hover:bg-cyan-50"
}`}
>
{category.label}
</Link>
);
})}
</>
);
}
function MobileCategoryLinksFallback({ onNavigate }: { onNavigate: () => void }) {
return (
<>
{categories.map((category) => (
<Link
key={category.label}
href={category.href}
prefetch={false}
onClick={onNavigate}
className="rounded-2xl border border-slate-200 px-4 py-3 font-extrabold transition hover:border-cyan-400 hover:bg-cyan-50"
>
{category.label}
<Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
Todo
</Link>
</div>
</nav>
);
}
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
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);
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div ref={containerRef} className="relative shrink-0" onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-haspopup="true"
className={`flex items-center gap-1.5 ${pillClass(open)}`}
>
<LayoutGrid className="size-4" />
Departamentos
<ChevronDown className={`size-3.5 opacity-70 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-1 w-[min(92vw,64rem)] rounded-2xl border border-slate-800 bg-slate-950 p-6 shadow-2xl">
<div className="grid grid-cols-2 gap-x-6 gap-y-5 sm:grid-cols-3 lg:grid-cols-6">
{tree.map((root) => (
<div key={root.id}>
<Link
href={`/catalog?category=${encodeURIComponent(root.name)}`}
prefetch={false}
aria-current={isActive(root.name) ? "page" : undefined}
onClick={() => setOpen(false)}
className={`block text-sm font-black uppercase tracking-wide transition ${
isActive(root.name) ? "text-fuchsia-400" : "text-white hover:text-fuchsia-400"
}`}
>
{root.name}
</Link>
{root.children.length > 0 && (
<ul className="mt-2.5 grid gap-1.5">
{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={() => setOpen(false)}
className={`block text-sm transition ${isActive(child.name) ? "font-bold text-violet-300" : "text-white/70 hover:text-white"}`}
>
{child.name}
</Link>
</li>
))}
</ul>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
function MobileCategoryLinks({ tree, onNavigate }: { tree: CategoryNode[]; onNavigate: () => void }) {
const { isActive, isAllActive } = useCategoryNavigationState();
return (
<>
<Link
href="/catalog"
prefetch={false}
aria-current={isAllActive ? "page" : undefined}
onClick={onNavigate}
className={mobileLinkClass(isAllActive)}
>
Todo el catálogo
</Link>
{tree.map((root) => (
<div key={root.id} className="contents">
<Link
href={`/catalog?category=${encodeURIComponent(root.name)}`}
prefetch={false}
aria-current={isActive(root.name) ? "page" : undefined}
onClick={onNavigate}
className={mobileLinkClass(isActive(root.name))}
>
{root.name}
</Link>
{root.children.map((child) => (
<Link
key={child.id}
href={`/catalog?category=${encodeURIComponent(child.name)}`}
prefetch={false}
aria-current={isActive(child.name) ? "page" : undefined}
onClick={onNavigate}
className={`${mobileLinkClass(isActive(child.name))} ml-4 text-sm`}
>
{child.name}
</Link>
))}
</div>
))}
</>
);
}
function MobileCategoryLinksFallback({ tree, onNavigate }: { tree: CategoryNode[]; onNavigate: () => void }) {
return (
<>
<Link href="/catalog" prefetch={false} onClick={onNavigate} className={mobileLinkClass(false)}>
Todo el catálogo
</Link>
{tree.map((root) => (
<div key={root.id} className="contents">
<Link href={`/catalog?category=${encodeURIComponent(root.name)}`} prefetch={false} onClick={onNavigate} className={mobileLinkClass(false)}>
{root.name}
</Link>
{root.children.map((child) => (
<Link
key={child.id}
href={`/catalog?category=${encodeURIComponent(child.name)}`}
prefetch={false}
onClick={onNavigate}
className={`${mobileLinkClass(false)} ml-4 text-sm`}
>
{child.name}
</Link>
))}
</div>
))}
</>
);
}
@@ -2,11 +2,12 @@
import Image from "next/image";
import Link from "next/link";
import { Heart, ShoppingBag, Sparkles } from "lucide-react";
import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
import { formatMoney } from "@/lib/utils";
import { formatMoney, LOW_STOCK_THRESHOLD } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store";
import { useWishlistStore } from "@/store/wishlist-store";
@@ -16,19 +17,63 @@ export function ProductCard({ product, priority = false }: { product: Product; p
const wishlistHydrated = useWishlistStore((state) => state.hasHydrated);
const toggleProduct = useWishlistStore((state) => state.toggleProduct);
const isFavorite = wishlistHydrated && wishlistProducts.some((item) => item.id === product.id);
const discountPercent =
product.compareAtPrice && product.price
? Math.round((1 - product.price / product.compareAtPrice) * 100)
: null;
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id);
const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0];
const lowStock =
selectedVariant?.inventoryQuantity !== null &&
selectedVariant?.inventoryQuantity !== undefined &&
selectedVariant.inventoryQuantity > 0 &&
selectedVariant.inventoryQuantity <= LOW_STOCK_THRESHOLD
? selectedVariant.inventoryQuantity
: null;
return (
<article className="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-lg shadow-slate-900/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-violet-500/10">
<div className="relative aspect-square overflow-hidden bg-slate-50">
<Link href={`/product/${product.slug}`} prefetch={false}><Image src={product.thumbnail} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px" className="object-cover transition duration-500 group-hover:scale-[1.035]" /></Link>
<div className="absolute left-3 top-3 flex gap-2">{product.isNew && <Badge className="bg-fuchsia-500 text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-yellow-300 text-slate-950"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
<div className="absolute left-3 top-3 flex gap-2">{discountPercent && discountPercent > 0 && <Badge className="bg-rose-600 text-white">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-fuchsia-500 text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-yellow-300 text-slate-950"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
<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-fuchsia-500 text-white" : "bg-white/95 text-slate-900 hover:text-fuchsia-500"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button>
</div>
<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-full bg-cyan-50 px-2 py-1 text-cyan-700">{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-slate-950 group-hover:text-fuchsia-600">{product.name}</h3></Link>
<p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p>
<div className="mt-auto flex items-center justify-between gap-3 pt-1"><strong className="text-sm font-black">{formatMoney(product.price, product.currency)}</strong><Button variant="magenta" size="sm" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag className="size-4" />Agregar</Button></div>
{product.variants.length > 1 && (
<div className="flex flex-wrap gap-1.5">
{product.variants.map((variant) => (
<button
key={variant.id}
type="button"
disabled={!variant.available}
aria-pressed={selectedVariantId === variant.id}
aria-label={`${variant.name}: ${variant.value}`}
onClick={() => setSelectedVariantId(variant.id)}
className={`rounded-full border px-2.5 py-1 text-xs font-bold transition ${
selectedVariantId === variant.id
? "border-fuchsia-500 bg-fuchsia-50 text-fuchsia-700"
: "border-slate-200 text-slate-600 hover:border-slate-400"
} ${!variant.available ? "cursor-not-allowed opacity-40" : ""}`}
>
{variant.value}
</button>
))}
</div>
)}
{lowStock !== null && (
<span className="flex items-center gap-1.5 text-xs font-bold text-amber-600"><TriangleAlert className="size-3.5" />Quedan {lowStock}</span>
)}
<div className="mt-auto flex items-center justify-between gap-3 pt-1">
<div className="flex flex-col">
{product.compareAtPrice && <span className="text-xs font-bold text-slate-400 line-through">{formatMoney(product.compareAtPrice, product.currency)}</span>}
<strong className="text-sm font-black">{formatMoney(product.price, product.currency)}</strong>
</div>
<Button variant="magenta" size="sm" onClick={() => addItem(product, selectedVariantId)}><ShoppingBag className="size-4" />Agregar</Button>
</div>
</div>
</article>
);
@@ -1,14 +1,22 @@
"use client";
import { CheckCircle2, ShoppingBag, Truck } from "lucide-react";
import { CheckCircle2, ShoppingBag, TriangleAlert, Truck } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
import { formatMoney } from "@/lib/utils";
import { formatMoney, LOW_STOCK_THRESHOLD } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store";
export function ProductPurchase({ product }: { product: Product }) {
const [variantId, setVariantId] = useState(product.variants[0]?.id);
const addItem = useCartStore((state) => state.addItem);
return <div><div className="mb-5 flex flex-wrap gap-2">{product.variants.map((variant) => <button key={variant.id} onClick={() => setVariantId(variant.id)} className={`rounded-full border px-4 py-2 text-sm font-bold ${variantId === variant.id ? "border-fuchsia-500 bg-fuchsia-50 text-fuchsia-700" : "border-slate-200"}`}>{variant.value}</button>)}</div><div className="mb-6 flex items-end gap-3"><strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}</div><Button variant="magenta" size="lg" className="w-full" onClick={() => addItem(product, variantId)}><ShoppingBag />Añadir al carrito</Button><div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm"><span className="flex items-center gap-2 font-bold"><CheckCircle2 className="size-4 text-emerald-500" />Disponibilidad por confirmar</span><span className="flex items-center gap-2 font-bold"><Truck className="size-4 text-cyan-600" />Envíos nacionales</span></div></div>;
const selectedVariant = product.variants.find((variant) => variant.id === variantId) ?? product.variants[0];
const lowStock =
selectedVariant?.inventoryQuantity !== null &&
selectedVariant?.inventoryQuantity !== undefined &&
selectedVariant.inventoryQuantity > 0 &&
selectedVariant.inventoryQuantity <= LOW_STOCK_THRESHOLD
? selectedVariant.inventoryQuantity
: null;
return <div><div className="mb-5 flex flex-wrap gap-2">{product.variants.map((variant) => <button key={variant.id} onClick={() => setVariantId(variant.id)} className={`rounded-full border px-4 py-2 text-sm font-bold ${variantId === variant.id ? "border-fuchsia-500 bg-fuchsia-50 text-fuchsia-700" : "border-slate-200"}`}>{variant.value}</button>)}</div><div className="mb-6 flex items-end gap-3"><strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}</div><Button variant="magenta" size="lg" className="w-full" onClick={() => addItem(product, variantId)}><ShoppingBag />Añadir al carrito</Button><div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm">{lowStock !== null ? <span className="flex items-center gap-2 font-bold text-amber-600"><TriangleAlert className="size-4" />Quedan {lowStock} unidades</span> : <span className="flex items-center gap-2 font-bold"><CheckCircle2 className="size-4 text-emerald-500" />Disponibilidad por confirmar</span>}<span className="flex items-center gap-2 font-bold"><Truck className="size-4 text-cyan-600" />Envíos nacionales</span></div></div>;
}
+34 -17
View File
@@ -30,7 +30,8 @@
"id": "ari-001-toy-story-5-rex-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-001-toy-story-5-rex.webp"
@@ -66,7 +67,8 @@
"id": "ari-002-toy-story-5-buzz-lightyear-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-002-toy-story-5-buzz-lightyear.webp"
@@ -102,7 +104,8 @@
"id": "ari-003-toy-story-5-jessie-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-003-toy-story-5-jessie.webp"
@@ -138,7 +141,8 @@
"id": "ari-004-toy-story-5-woody-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-004-toy-story-5-woody.webp"
@@ -174,7 +178,8 @@
"id": "ari-005-intensamente-furia-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-005-intensamente-furia.webp"
@@ -210,7 +215,8 @@
"id": "ari-006-intensamente-tristeza-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-006-intensamente-tristeza.webp"
@@ -246,7 +252,8 @@
"id": "ari-007-minnie-y-mickey-mouse-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-007-minnie-y-mickey-mouse.webp"
@@ -282,7 +289,8 @@
"id": "ari-008-angel-lilo-y-stitch-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-008-angel-lilo-y-stitch.webp"
@@ -318,7 +326,8 @@
"id": "ari-009-mochilas-disney-coleccion-marvel-default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-009-mochilas-disney-coleccion-marvel.webp"
@@ -354,7 +363,8 @@
"id": "ari-010-mochila-ligera-y-resistente-al-agua-de-d-default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp"
@@ -390,7 +400,8 @@
"id": "ari-011-mochila-escolar-de-spider-man-de-disney--default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-011-mochila-escolar-de-spider-man-de-disney-.webp"
@@ -426,7 +437,8 @@
"id": "ari-012-disney-mochila-de-poliester-ultraligera--default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-012-disney-mochila-de-poliester-ultraligera-.webp"
@@ -462,7 +474,8 @@
"id": "ari-013-mochila-pequena-bob-esponja-default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-013-mochila-pequena-bob-esponja.webp"
@@ -498,7 +511,8 @@
"id": "ari-014-mochila-casual-de-gran-capacidad-con-est-default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-014-mochila-casual-de-gran-capacidad-con-est.webp"
@@ -534,7 +548,8 @@
"id": "ari-015-mochila-escolar-estilo-britanico-de-disn-default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-015-mochila-escolar-estilo-britanico-de-disn.webp"
@@ -570,7 +585,8 @@
"id": "ari-016-disney-mochila-iron-man-de-poliester-ult-default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-016-disney-mochila-iron-man-de-poliester-ult.webp"
@@ -606,7 +622,8 @@
"id": "ari-017-mochila-de-disney-marvel-capitan-america-default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-017-mochila-de-disney-marvel-capitan-america.webp"
@@ -25,7 +25,7 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.129
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.157
env:
- name: HEADLESS_PROVIDER
value: medusa
@@ -64,9 +64,20 @@ spec:
# binario de node para esto, pero eso solo funciona si el
# bounding set del contenedor lo permite -- drop: ALL del base
# lo bloquea igual, root o no root. Mismo motivo que docs-portal.
#
# readOnlyRootFilesystem: true -- revisado en código (2026-08-24,
# aún no probado en vivo con este cambio) que esta imagen sí
# escribe a disco en runtime, no solo en build: el servidor
# standalone de Next.js cachea a ./.next/cache tanto el
# optimizador de imágenes (next.config.ts trae remotePatterns,
# sin "unoptimized: true") como el revalidateTag("medusa-products")
# de app/api/revalidate/route.ts (cache handler por default, sin
# uno custom tipo Redis). De ahí los dos volumeMounts de abajo:
# sin ellos, con root filesystem de solo lectura, ambas rutas
# fallarían al intentar escribir.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
@@ -79,3 +90,13 @@ spec:
limits:
cpu: "1"
memory: 768Mi
volumeMounts:
- name: next-cache
mountPath: /app/.next/cache
- name: tmp
mountPath: /tmp
volumes:
- name: next-cache
emptyDir: {}
- name: tmp
emptyDir: {}
@@ -22,3 +22,7 @@ export async function getProductBySlug(slug: string) {
export async function searchProducts(query: string, limit?: number) {
return provider().searchProducts(query, limit);
}
export async function getCategories() {
return provider().getCategories();
}
@@ -1,5 +1,6 @@
import type {
CatalogProvider,
Category,
Product,
ProductFilters,
ProductVariant,
@@ -66,6 +67,18 @@ type MedusaProductsResponse = {
offset?: number;
};
type MedusaProductCategory = {
id: string;
name?: string | null;
handle?: string | null;
parent_category_id?: string | null;
rank?: number | null;
};
type MedusaCategoriesResponse = {
product_categories: MedusaProductCategory[];
};
function backendUrl(): string {
const value = process.env.MEDUSA_BACKEND_URL;
if (!value) {
@@ -144,11 +157,19 @@ function mapVariant(variant: MedusaVariant): ProductVariant {
variant.manage_inventory === false ||
(variant.inventory_quantity ?? 0) > 0;
// null = inventario no gestionado (backorder o manage_inventory=false),
// no hay un numero real que mostrar como "quedan N".
const inventoryQuantity =
variant.allow_backorder === true || variant.manage_inventory === false
? null
: (variant.inventory_quantity ?? 0);
return {
id: variant.id,
name: option?.option?.title || "Presentación",
value: option?.value || variant.title || "Estándar",
available,
inventoryQuantity,
};
}
@@ -339,11 +360,40 @@ async function fetchProducts(
.filter((product) => matchesFilters(product, filters));
}
async function fetchCategories(): Promise<Category[]> {
const params = new URLSearchParams({
limit: "100",
fields: "id,name,handle,rank,parent_category_id",
});
const response = await medusaFetch<MedusaCategoriesResponse>(
"/store/product-categories",
params,
);
// Ordenar por rank preserva el orden de la taxonomia tal como se creo
// (ver ADR 0005) -- los hijos de cada padre quedan en el mismo orden
// relativo porque el arbol se arma filtrando este arreglo ya ordenado.
return response.product_categories
.slice()
.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))
.map((category) => ({
id: category.id,
name: category.name || "",
handle: category.handle || category.id,
parentId: category.parent_category_id ?? null,
}));
}
export const medusaCatalogProvider: CatalogProvider = {
async getProducts(filters) {
return fetchProducts(filters);
},
async getCategories() {
return fetchCategories();
},
async getProductBySlug(slug) {
const params = new URLSearchParams({
handle: slug,
@@ -1,5 +1,5 @@
import catalog from "@/data/products.json";
import type { CatalogProvider, Product, ProductFilters } from "@/lib/headless/types";
import type { CatalogProvider, Category, Product, ProductFilters } from "@/lib/headless/types";
const products = catalog satisfies Product[];
@@ -39,4 +39,16 @@ export const mockCatalogProvider: CatalogProvider = {
async searchProducts(query, limit = 6) {
return products.filter((product) => matches(product, { query })).slice(0, limit);
},
async getCategories(): Promise<Category[]> {
// El catalogo mock no tiene jerarquia real (a diferencia de Medusa) --
// se devuelve una lista plana de las categorias que ya usan los
// productos mock, todas sin padre.
const names = [...new Set(products.map((product) => product.category))].filter(Boolean);
return names.map((name) => ({
id: name,
name,
handle: normalize(name).replace(/\s+/g, "-"),
parentId: null,
}));
},
};
@@ -42,4 +42,7 @@ export const shopifyCatalogProvider: CatalogProvider = {
async searchProducts(): Promise<Product[]> {
return notImplemented();
},
async getCategories() {
return notImplemented();
},
};
@@ -3,6 +3,7 @@ export interface ProductVariant {
name: string;
value: string;
available: boolean;
inventoryQuantity: number | null;
}
export interface Product {
@@ -39,8 +40,16 @@ export interface ProductFilters {
maxPrice?: number;
}
export interface Category {
id: string;
name: string;
handle: string;
parentId: string | null;
}
export interface CatalogProvider {
getProducts(filters?: ProductFilters): Promise<Product[]>;
getProductBySlug(slug: string): Promise<Product | null>;
searchProducts(query: string, limit?: number): Promise<Product[]>;
getCategories(): Promise<Category[]>;
}
+5
View File
@@ -13,3 +13,8 @@ export function formatMoney(value: number | null, currency = "COP"): string {
maximumFractionDigits: 0,
}).format(value);
}
// Umbral desde el cual mostramos "Quedan N" en vez de solo "Disponible".
// inventoryQuantity=null significa inventario no gestionado (backorder /
// manage_inventory=false en Medusa) -- ahi nunca mostramos el mensaje.
export const LOW_STOCK_THRESHOLD = 5;