Compare commits

..
Author SHA1 Message Date
gitea-actions 68adb32663 chore(gitops): deploy Docs Portal v1.0.175 [skip ci] 2026-08-27 05:25:34 +00:00
devops f761844cd0 Merge fix/adr0007-identidad-visual-propia into main
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 16s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 7m9s
2026-08-27 00:18:10 -05:00
devops 31fd800734 docs(adr): ADR 0007 - identidad visual propia para ARI Shopping
Propuesta de rediseno visual del storefront, aprobada por el usuario
tras una correccion de direccion explicita: no clonar la estetica de
Amazon, adoptar los principios de UX del mercado (jerarquia visual,
densidad sin saturacion, senales de confianza, contraste) para
construir una identidad propia y distintiva -- paleta terracota/
espresso/pino/crema validada con contraste WCAG AA real, tipografia
Fraunces+Inter, lenguaje de forma propio, 3 detalles deliberados no
genericos.

Alcance: solo visual, no toca catalogo/backend/taxonomia ni la
estructura de navegacion ya construida en ADR 0005. Plan de
implementacion en 5 sub-fases (tokens -> header/footer -> product
card+catalog -> home -> product detail/drawers), sin codigo de
produccion tocado todavia.
2026-08-27 00:18:10 -05:00
gitea-actions 9da759617c chore(gitops): deploy Docs Portal v1.0.174 [skip ci] 2026-08-27 04:57:05 +00:00
devops 949788cda7 Merge fix/docs-portal-fase2-contenido-narrativo 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 5m22s
2026-08-26 23:51:18 -05:00
devops 056a7fb76a docs(docs-portal): Fase 2 - contenido narrativo real de arquitectura/decisiones/aprendizajes
Reemplaza los placeholders "TODO (fase 2+)" por narrativa completa,
grounded en el codigo/manifiestos/incidentes reales del lab (no
generico):

- ADR 0001 (por que k3d) y ADR 0002 (por que Argo CD): pasan de
  template vacio a Contexto/Opciones/Decision/Consecuencias reales,
  apoyados en known-issues.md (blackhole MTU) y en la config real de
  ecommerce-app.yaml.
- vision-general.md: el "por que" de cada frontera de confianza del
  diagrama.
- gitops-flujo.md: ejemplo real con hashes de commit (Fase 3b de
  ADR 0006, v1.0.171 -> hotfix -> v1.0.172), selfHeal explicado con el
  syncPolicy real del repo.
- red-y-exposicion.md: diagrama de terminacion TLS real, por que todo
  va "proxied", patron de diagnostico "interno sano, publico roto".
- glosario.md: 9 terminos nuevos, cada uno con ejemplo real y
  verificado del repo (Namespace, ResourceQuota, LimitRange,
  NetworkPolicy, Deployment, Service, Ingress, StatefulSet, Init
  container).
- notas-sueltas.md: MinIO/S3, limites reales del subscriber
  revalidate-storefront.ts, que se automatizaria desde cero.

Validado con `docker build --target build` (mkdocs build --strict,
mismo paso que el pipeline real) -- cero links rotos, cero warnings.
2026-08-26 23:51:18 -05:00
gitea-actions b7443a034f chore(gitops): deploy Docs Portal v1.0.173 [skip ci] 2026-08-27 04:46:06 +00:00
devops 4ee892a43a Merge fix/adr0005-cerrar-pendiente-demo-audit into main
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 5m1s
2026-08-26 23:40:53 -05:00
devops c6c66b76a3 docs(adr0005): cerrar pendiente de usuario admin temporal
[email protected] (creado para la auditoria de Etapa 1) fue
borrado 2026-08-27 via Medusa Admin UI -- ya no queda pendiente
abierto en el ADR.
2026-08-26 23:40:53 -05:00
devops 7406aff34f chore: release v1.0.172 [skip ci] 2026-08-27 04:29:21 +00:00
devops ba3e6c7322 Merge fix/adr0006-etapa2-fase3b-count-limit0-bug into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 17s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m22s
2026-08-26 23:20:40 -05:00
devops 884dbd1d93 fix(ecommerce): total de facetas no depende de count con limit=0
Verificado en vivo contra shop.cruzcloud.net: /store/search reporta
estimatedTotalHits (campo `count`) en 0 cuando limit=0, aunque
facetDistribution si trae los valores correctos (limit>=1 lo reporta
bien). El bucket "Todas"/"Todos" del sidebar de facetas (Fase 3b)
dependia de ese campo -- ahora suma los valores de la propia
facetDistribution, que da el total real sin ese problema.
2026-08-26 23:20:39 -05:00
devops 951682a4d4 chore: release v1.0.171 [skip ci] 2026-08-27 04:06:42 +00:00
devops 6b5450f16f Merge fix/adr0006-etapa2-fase3b-facetas-sidebar into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 37s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 10m53s
2026-08-26 22:55:13 -05:00
devops 83d448b282 feat(ecommerce): facetas reales de Meilisearch en el sidebar del catalogo
Fase 3b (ultima) de ADR 0006 Etapa 2: los conteos de categoria/marca/edad
del sidebar de /catalog dejan de ser un snapshot client-side fijo. Ahora
se refetchean en vivo contra /api/search en cada cambio de filtro (query
debounced 300ms), tres llamadas paralelas -- una por dimension, cada una
omitiendo su propio filtro para preservar el patron Amazon de "cuantos
resultados habria si eligiera este otro valor".

- catalog-client.tsx: useMemo client-side originales pasan a ser fallback
  (mock/shopify sin searchWithFacets, o cuando la categoria activa es un
  grupo raiz sintetico que Meilisearch no puede filtrar).
- ProductFilters gana `limit` opcional; /api/search y el provider medusa
  lo reenvian -- las llamadas de solo-conteo piden limit=0 para no
  rehidratar precios de productos que no se van a mostrar.
2026-08-26 22:53:49 -05:00
devops 1ecd2015a4 chore: release v1.0.170 [skip ci] 2026-08-26 22:38:31 +00:00
devops 609fc19252 Merge fix/adr0006-etapa2-fase3a-buscador-navbar into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m16s
2026-08-26 17:30:54 -05:00
devops 902350b58b Fase 3a ADR 0006 Etapa 2: buscador del navbar via Meilisearch
PredictiveSearch pasa de filtrar en memoria el arreglo completo de
productos (prop products, substring match) a un fetch debounced
(250ms, con AbortController) contra la nueva ruta /api/search --
mismo componente visual, solo cambia de donde salen los datos, tal
como pide el ADR 0006 seccion 5.

/api/search (nueva, server-side) es el puente same-origin: el
componente es cliente y no puede pegarle directo al provider
server-side (MEDUSA_BACKEND_URL es DNS interno del cluster, no
resuelve desde el navegador).

CatalogProvider gana un metodo OPCIONAL searchWithFacets? (no cambia
la firma de getProducts/searchProducts) -- mock.ts y shopify.ts
compilan sin tocarlos, siguen sin implementarlo. medusa.ts lo
implementa contra /store/search (Fase 2), reutilizando mapProduct()
ya que la ruta devuelve el mismo shape de producto que /store/products.
searchProducts tambien se repunta a /store/search, dejando de usar el
?q= + re-filtro client-side de fetchProducts/matchesFilters para busqueda
(que sigue sirviendo para navegacion sin busqueda, sin cambios).

Navbar deja de pasarle products a PredictiveSearch (ya no los necesita)
pero los sigue recibiendo el mismo -- collections todavia se deriva de
ahi.

Limpieza: borrado patches/frontend-medusa-env.yaml, confirmado
huerfano (ningun kustomization lo referencia; el patch que si esta
vivo es frontend/patches/deployment.yaml).

Validado: build completo de Docker (target builder) -- npm ci,
next build con typecheck, /api/search aparece en la tabla de rutas
generada. Cero errores nuevos (los warnings de ESLint son
preexistentes en catalog-client.tsx, no tocado en esta fase).
2026-08-26 17:30:47 -05:00
gitea-actions 7d2c03f10f chore(gitops): deploy Docs Portal v1.0.169 [skip ci] 2026-08-26 22:16:37 +00:00
devops 72ae63f2e3 Merge fix/adr0006-nota-implementacion-2-gate-fase2 into main
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 4m2s
2026-08-26 17:12:13 -05:00
devops 4afc63bdc6 docs(adr): nota de implementacion 2 -- hallazgos del gate en vivo de Fase 2 2026-08-26 17:12:10 -05:00
gitea-actions ed9891577f chore(gitops): deploy Medusa v1.0.168 [skip ci] 2026-08-26 22:03:37 +00:00
devops 41c7ad25e7 Merge fix/adr0006-etapa2-fase2-currency-code-search into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 18s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 11m17s
2026-08-26 16:52:06 -05:00
devops 2056e6c766 Fase 2 fix: /store/search resuelve currency_code, no solo region_id
Confirmado en vivo: pasar solo region_id a QueryContext no alcanza --
"Method calculatePrices requires currency_code in the pricing
context" persistia. A diferencia de /store/products nativo (resuelto
por el middleware setPricingContext), esta ruta propia no pasa por esa
cadena, asi que resuelve la moneda real de la region con un
query.graph adicional antes de pedir calculated_price -- no asume COP
fijo por si el catalogo suma otra region/moneda despues.
2026-08-26 16:52:04 -05:00
gitea-actions f2377be69c chore(gitops): deploy Medusa v1.0.167 [skip ci] 2026-08-26 21:44:33 +00:00
devops d6b84e4480 Merge fix/adr0006-etapa2-fase2-region-id-search into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 24s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 12m49s
2026-08-26 16:31:24 -05:00
gitea-actions 51eadd98dc chore(gitops): deploy Medusa v1.0.166 [skip ci] 2026-08-26 21:23:08 +00:00
23 changed files with 1084 additions and 128 deletions

No files matched your search

@@ -149,13 +149,36 @@ export async function GET(req: MedusaRequest, res: MedusaResponse) {
return;
}
// calculatePrices exige currency_code en el contexto -- no alcanza con
// region_id solo (a diferencia de /store/products nativo, que lo
// resuelve el middleware setPricingContext; esta ruta no pasa por esa
// cadena). Resolvemos la moneda real de la region en vez de asumir COP
// fijo, por si el catalogo suma otra region/moneda mas adelante.
let currencyCode: string | undefined;
if (regionId) {
const { data: regions = [] } = await query.graph({
entity: "region",
fields: ["id", "currency_code"],
filters: { id: regionId },
});
currencyCode = regions[0]?.currency_code;
}
const { data: products = [] } = await query.graph({
entity: "product",
fields: PRODUCT_FIELDS,
filters: { id: productIds },
context: regionId
? { variants: { calculated_price: QueryContext({ region_id: regionId }) } }
: {},
context:
regionId && currencyCode
? {
variants: {
calculated_price: QueryContext({
region_id: regionId,
currency_code: currencyCode,
}),
},
}
: {},
});
// Preserva el ranking de relevancia de Meilisearch -- query.graph no
@@ -1,20 +1,9 @@
# Notas sueltas / aprendizajes
> **TODO (fase 2+):** esto es un scratchpad estructurado, no un
> playbook — va creciendo con notas cortas a medida que aparecen. Las
> entradas de abajo son semillas reales extraídas de incidentes ya
> documentados; expandir cada una con más contexto en fases siguientes.
## Qué haría distinto
- **Fijar `TUNNEL_TRANSPORT_PROTOCOL=http2` desde el día uno**, no
después de un incidente — QUIC/UDP sobre NAT doméstico fue un problema
predecible en retrospectiva. Ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md).
- **Separar siempre "chequeo de disponibilidad" de "migraciones"** en
init containers distintos, y que el chequeo use el mínimo de
parámetros posible (host/puerto/usuario/db) en vez de reusar una URI
de conexión pensada para la app. Confirmado como decisión correcta en
el [incidente de crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md).
> Esto es un scratchpad estructurado, no un playbook — sigue creciendo
> con notas cortas a medida que aparecen. Cada entrada está extraída de
> un incidente o hallazgo real ya documentado en otra parte del sitio,
> no es especulación.
## Gotchas de Medusa v2
@@ -26,8 +15,70 @@
del array `images` — hay que setearlo explícito desde el Admin si el
frontend lo usa como imagen hero en listados.
## TODO
## MinIO / S3 provider
- Notas sobre MinIO / S3 provider.
- Notas sobre el subscriber `revalidate-storefront.ts` y sus límites.
- Qué automatizaría si volviera a montar este lab desde cero.
- Medusa sube imágenes de producto a MinIO (`minio-svc:9000`, bucket
`ari-products`) usando el provider S3-compatible estándar — MinIO no
sabe que no es AWS real, y Medusa tampoco necesita saberlo.
- La URL pública que termina viendo el navegador **no** es la interna
(`minio-svc:9000`): es `S3_FILE_URL=https://media.cruzcloud.net/ari-products`,
un dominio propio proxied por NPM hacia el mismo MinIO. Esto tiene
una consecuencia directa que ya rompió producción una vez: si
`media.cruzcloud.net` deja de rutear bien (mismo patrón de
`forward_host` roto que el resto del sitio), **todas** las imágenes
de producto del storefront se caen aunque MinIO y Medusa estén
perfectamente sanos — porque el navegador nunca habla con MinIO
directo, solo con esa URL pública. Ver [Red y exposición](../arquitectura/red-y-exposicion.md).
## Subscriber `revalidate-storefront.ts` y sus límites
- Escucha 6 eventos de Medusa (`product.created/updated/deleted` +
los 3 equivalentes de `product-variant`) y por cada uno hace un
único `POST` a `${STOREFRONT_URL}/api/revalidate` con un header
secreto (`x-revalidate-secret`) — esa ruta del frontend llama
`revalidateTag("medusa-products")` de Next.js.
- **Se degrada solo, no tira el evento entero**: si
`STOREFRONT_URL`/`REVALIDATE_SECRET` no están configurados, o el
`fetch` falla, solo loguea un `warn` y sigue — un fallo de red hacia
el frontend nunca revierte ni bloquea la operación real en Medusa
(crear/editar un producto). Mismo criterio de "degradar, no romper"
que se repite en el resto del storefront (`searchWithFacets`
opcional, `/api/search` cayendo a `{}` si Meilisearch no responde).
- **Límite real, todavía no un problema**: no hay debounce ni
deduplicación — si una operación dispara varios eventos juntos (ej.
editar un producto con 3 variantes a la vez), son varios `POST`
independientes al mismo endpoint. `revalidateTag` de Next.js es
barato e idempotente, así que hoy no se nota, pero si el catálogo
creciera mucho y las ediciones masivas se volvieran frecuentes, sería
el primer lugar donde agregar algún tipo de agrupación.
## Qué haría distinto si volviera a montar este lab desde cero
- **Fijar `TUNNEL_TRANSPORT_PROTOCOL=http2` desde el día uno** (ver
[ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)) y
**ajustar el MTU de `flannel.1` desde el primer `deploy-lab.sh`** —
ambos terminaron siendo incidentes reales
(504 intermitente, blackhole cross-node) que en retrospectiva eran
predecibles: red doméstica con NAT agresivo + Docker-in-Docker
multi-nodo son una combinación conocida para tener problemas de MTU
y de transporte UDP. Hoy los dos ya están automatizados
(`fix_flannel_mtu` en `deploy-lab.sh`, la variable de entorno del
túnel), pero se aprendieron reactivamente, no se anticiparon.
- **Usar `git merge-base --is-ancestor` para la promoción segura del
CI desde el primer pipeline**, no después de ver la condición de
carrera en vivo (dos pipelines corriendo en serie sobre el mismo
commit, el segundo pisando o saltándose la promoción del primero).
El patrón correcto no es complicado una vez que se conoce — el costo
fue no anticiparlo.
- **Separar siempre "chequeo de disponibilidad" de "migraciones"** en
init containers distintos, y que el chequeo use el mínimo de
parámetros posible (host/puerto/usuario/db) en vez de reusar una URI
de conexión pensada para la app. Confirmado como decisión correcta en
el [incidente de crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md).
- **No escribir un script que regenere credenciales completas en cada
corrida** (`create-commerce-secrets.sh` rota JWT/COOKIE/passwords de
Postgres/Redis/MinIO enteros cada vez que se ejecuta, incluso si solo
hacía falta agregar una clave nueva) — un script de setup inicial y
uno de "agregar una clave a un secret ya vivo" deberían haber sido
dos herramientas distintas desde el principio, no una sola que hay
que saber cuándo NO correr completa.
@@ -1,9 +1,9 @@
# Del commit al despliegue: flujo GitOps
> **TODO (fase 2+):** narrar cada paso con ejemplos reales tomados de
> `.gitea/workflows/build-medusa.yaml` y `build.yaml`. El diagrama de
> secuencia de abajo refleja el pipeline real (build → push imagen →
> promoción segura → commit del tag → sync de Argo CD).
El diagrama de abajo refleja el pipeline real (build → push imagen →
promoción segura → commit del tag → sync de Argo CD). Después del
diagrama hay un ejemplo con hashes de commit reales, tomado de un
cambio real hecho sobre este mismo repo.
## Secuencia: un commit hasta quedar corriendo en el cluster
@@ -42,9 +42,53 @@ sequenceDiagram
evita que un pipeline viejo sobreescriba el tag con una versión
anterior si hubo un push más reciente mientras corría.
## TODO
## Ejemplo real: un commit de principio a fin
- Ejemplo real con hashes de commit (tomar del historial de
`apps-registry`).
- Explicar `syncPolicy.automated.selfHeal` con un caso concreto (qué
pasa si alguien hace `kubectl edit` a mano).
Fase 3b del ADR 0006 (facetas del sidebar del catálogo, ver
`decisiones/0006-motor-busqueda-facetas.md`) recorrió el flujo
completo dos veces seguidas el mismo día:
| Paso | Commit / evento real |
|---|---|
| Feature | `83d448b` — `feat(ecommerce): facetas reales de Meilisearch en el sidebar del catalogo`, en `fix/adr0006-etapa2-fase3b-facetas-sidebar` |
| Merge a `main` | `6b5450f` |
| CI build+push | Gitea Actions task 258, imagen `gitea.cruzcloud.net/devops/ecommerce-frontend` |
| Release commit (CD) | `951682a` — `chore: release v1.0.171 [skip ci]`, bump automático del tag en `frontend/patches/deployment.yaml` |
| Sync de Argo CD | dos ciclos distintos: primero sincronizó contra `6b5450f` (el merge, todavía con el tag viejo), y en el ciclo siguiente detectó `951682a` y aplicó el tag nuevo |
| Rollout | `frontend-deploy` con 2/2 réplicas en `v1.0.171` |
El gate en vivo contra ese mismo cambio encontró un bug real
(`estimatedTotalHits` en 0 con `limit=0` en Meilisearch) — el fix
repitió el mismo ciclo completo: `884dbd1` → merge `ba3e6c7` → release
`v1.0.172` → sync → rollout. Dos vueltas completas del diagrama de
arriba en la misma sesión, cada una verificada contra el endpoint real
antes de darla por cerrada.
**Detalle no obvio:** Argo CD sincroniza el *merge commit* primero
(todavía con la imagen vieja, porque el bump de tag es un commit
*posterior* que hace el propio pipeline) y recién en un segundo ciclo
de poll detecta el commit de release. Son dos sincronizaciones
distintas, no una sola — hay que esperar ambas antes de asumir que el
pod nuevo está corriendo.
## `selfHeal`, con la configuración real
Todas las Applications de este repo comparten el mismo `syncPolicy`
(`apps/ecommerce-app.yaml`):
```yaml
syncPolicy:
automated:
prune: true
selfHeal: true
```
Con `selfHeal: true`, si alguien corre `kubectl scale deployment
frontend-deploy --replicas=1` directo contra el cluster (saltándose
Git por completo), Argo CD no se queda en mostrar `OutOfSync` — en el
siguiente ciclo de reconciliación vuelve a aplicar el manifiesto tal
como está en `main` y el Deployment queda otra vez en las réplicas
declaradas ahí. El cambio manual no se pierde silenciosamente: queda
un instante como drift visible en la UI de Argo CD, pero nunca se
vuelve el estado final. La única forma de que ese cambio persista es
que exista en Git — lo mismo que describe [ADR 0002](../decisiones/0002-por-que-argocd-vs-manual.md).
@@ -65,9 +65,142 @@ plantillas (a diferencia de Helm).
incluir y qué parches aplicarles, sin necesidad de un lenguaje de
templating separado.
## TODO
## Namespace
- Namespace, ResourceQuota, LimitRange, NetworkPolicy
- Ingress vs. Service vs. Deployment
- StatefulSet (por qué Postgres lo usa y el frontend no)
- Init container
**En una frase:** una partición lógica dentro del mismo cluster físico
— agrupa recursos relacionados y les pone un límite alrededor.
**Detalle:** este lab tiene un namespace por dominio de negocio
(`ecommerce`, `docs-portal`) más `argocd` para el propio Argo CD. Todo
lo que gobierna a un namespace (cuotas, políticas de red) vive en
`platform-infra`, separado de los manifiestos de la app — así una app
nueva no puede "olvidarse" de traer su propia gobernanza.
## ResourceQuota
**En una frase:** el techo total de recursos que un namespace entero
puede consumir, sumando todos sus pods.
**Detalle:** la cuota real de `ecommerce`
(`platform-infra/ecommerce-governance/resourcequota.yaml`):
```yaml
hard:
requests.cpu: "3"
requests.memory: 4Gi
limits.cpu: "8"
limits.memory: 8Gi
pods: "12"
persistentvolumeclaims: "6"
requests.storage: 80Gi
```
Si un Deployment nuevo pidiera más `requests.memory` del que queda
disponible bajo ese techo, Kubernetes rechaza el pod directamente — no
lo deja arrancar y luego fallar.
## LimitRange
**En una frase:** los valores por defecto (y los mínimos/máximos) que
aplican a un contenedor *individual* dentro de un namespace, para
cuando el manifiesto no los declara explícitos.
**Detalle:** complementa a `ResourceQuota` (que mira el namespace
entero) a nivel de un solo contenedor. La de `ecommerce`
(`limitrange.yaml`) pone `defaultRequest: 100m/128Mi`,
`default: 500m/512Mi` (límites si el contenedor no los declara), y un
rango permitido de `10m/16Mi` a `2 CPU/1Gi` por contenedor — evita
tanto el contenedor que no pide nada y termina compitiendo mal por
recursos, como el que pide de más y se come la cuota del namespace
entero él solo.
## NetworkPolicy
**En una frase:** un firewall a nivel de Kubernetes — qué tráfico de
red se permite entrar (o salir) de un pod.
**Detalle:** sin ninguna `NetworkPolicy`, todos los pods de un cluster
pueden hablarse entre sí libremente. La de `ecommerce`
(`networkpolicy.yaml`) usa `podSelector: {}` (aplica a todos los pods
del namespace) y permite ingress solo desde `namespaceSelector: {}`
(cualquier namespace del cluster, típicamente Traefik llegando desde
`kube-system` o el namespace de ingress) — bloquea tráfico externo al
cluster que intentara saltarse Traefik y pegarle directo a un Service
interno.
## Deployment
**En una frase:** el controlador para cargas de trabajo **sin
estado** — réplicas intercambiables entre sí, ninguna con identidad
propia.
**Detalle:** `frontend-deploy` es un Deployment de 2 réplicas: no
importa cuál de las dos atienda una petición, ambas sirven exactamente
lo mismo y ninguna guarda datos propios en disco. Si un pod muere,
Kubernetes crea otro cualquiera para reemplazarlo — no hay "el pod
original" que recuperar. Contrastar con **StatefulSet** abajo.
## Service
**En una frase:** un nombre de red estable que apunta a un grupo de
pods, aunque esos pods cambien.
**Detalle:** los pods son efímeros (mueren y se recrean con IP nueva
cada vez); un Service (ej. `frontend-svc`, `postgres-svc`) les da un
nombre DNS interno fijo (`frontend-svc.ecommerce.svc.cluster.local`,
o simplemente `frontend-svc` desde dentro del mismo namespace) que no
cambia aunque los pods de atrás sí. `postgres-svc` en este lab es
además un Service `clusterIP: None` ("headless") — necesario porque
`commerce-postgres` es un StatefulSet y cada pod necesita su propia
identidad de red estable, no una IP compartida balanceada entre
réplicas.
## Ingress
**En una frase:** la regla que dice "las peticiones a este dominio y
esta ruta van a este Service", para tráfico HTTP que entra al cluster.
**Detalle:** `ecommerce-ingress` enruta por *path*, no solo por
dominio: `shop.cruzcloud.net/api/catalog` va a `catalog-api-svc`, y
todo lo demás (`/`) va a `frontend-svc` — un solo objeto Ingress con
dos backends distintos según el prefijo de la URL. Es Traefik (el
Ingress Controller del cluster) quien lee estos objetos y hace el
enrutamiento real; el Ingress en sí es solo la declaración de la
regla, no el componente que la ejecuta.
## StatefulSet
**En una frase:** el controlador para cargas de trabajo **con
estado** — cada réplica tiene identidad propia y almacenamiento propio
que sobrevive a que el pod se reinicie.
**Detalle:** `commerce-postgres` es un StatefulSet, no un Deployment,
porque el frontend puede tener 2 réplicas idénticas sin datos propios,
pero Postgres no — sus datos tienen que vivir en un disco específico
que sobreviva un reinicio del pod y sepa exactamente cuál pod es
"el suyo". Cada pod de un StatefulSet obtiene su propio
`PersistentVolumeClaim` vía `volumeClaimTemplates` (acá,
`postgres-data`, 10Gi en `local-path`) y un nombre de red estable y
numerado (`commerce-postgres-0`), a diferencia de los nombres
aleatorios de un Deployment. Meilisearch, Redis y MinIO en este lab
siguen el mismo patrón por la misma razón — todos guardan datos en
disco que tienen que sobrevivir al pod.
## Init container
**En una frase:** un contenedor que corre **antes** que los
contenedores principales de un pod, y tiene que terminar bien (exit
0) para que el resto arranque.
**Detalle:** `medusa-deploy` usa dos, y a propósito están separados en
vez de ser uno solo: `wait-for-postgres` (`pg_isready` en loop, solo
espera a que Postgres acepte conexiones) y `migrations`
(`db:migrate`, corre las migraciones de verdad). Un incidente real
mostró por qué separarlos importa: cuando el chequeo de disponibilidad
reusaba la misma `DATABASE_URL` completa de la app (con
`?sslmode=disable` incluido), el parser de `pg_isready` no la entendía
y el pod quedaba atascado en `Init` — reportado como si fuera
`CrashLoopBackOff`, sin serlo. El fix fue que el chequeo de
disponibilidad use el mínimo de parámetros posible
(host/puerto/usuario/db), nunca la URI completa pensada para la app.
Ver el [playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md).
@@ -1,8 +1,7 @@
# Red y exposición
> **TODO (fase 2+):** explicar cada dominio, por qué está proxied vs
> DNS-only en Cloudflare, y el manejo de TLS extremo a extremo. Tabla
> de dominios abajo es real, pendiente de expandir con capturas/ejemplos.
Los 5 dominios de abajo son reales y comparten exactamente el mismo
camino de red — ninguno tiene un caso especial.
## Dominios activos
@@ -14,12 +13,63 @@
| `gitea.cruzcloud.net` | Gitea | — | Cloudflare (edge) |
| `docs.cruzcloud.net` | Este portal | `docs` | Cloudflare (edge) |
## TODO
## Por qué todos van "proxied" (naranja), ninguno "DNS-only"
- Diagrama de TLS termination (dónde termina TLS realmente: en
Cloudflare, no en Traefik — el tráfico interno NPM→k3d es HTTP plano).
- Explicar por qué el transporte del túnel se fijó a HTTP/2 en vez de
QUIC (ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)
y el [playbook del 504](../playbooks/incidente-504-gitea-tunnel.md)).
- Patrón de diagnóstico "interno sano, público roto" (ver
[playbook de imágenes NPM](../playbooks/incidente-imagenes-npm.md)).
Un registro DNS-only en Cloudflare expondría directamente la IP real
del túnel — pero acá no hay IP pública fija que exponer: el NAS está
detrás de NAT residencial. El modo proxied es lo que hace que
Cloudflare Tunnel tenga sentido en primer lugar (el tráfico entra por
el edge de Cloudflare, nunca directo al origen), así que no hay ningún
dominio de este lab para el que DNS-only sea una opción real.
## Dónde termina TLS de verdad
```mermaid
flowchart LR
User(["Usuario"]) -->|HTTPS| CF["Cloudflare edge<br/>🔒 TLS termina acá"]
CF -->|túnel cifrado, HTTP/2| CFD["cloudflared"]
CFD -->|HTTP plano| NPM["Nginx Proxy Manager"]
NPM -->|HTTP plano| LB["k3d-proxy :90"]
LB -->|HTTP plano| TR["Traefik"]
TR -->|HTTP plano| SVC["Service interno"]
```
TLS termina en el **edge de Cloudflare**, no en Traefik ni en NPM. De
ahí hacia adentro (`cloudflared` → NPM → `k3d-proxy` → Traefik →
Service) todo el tráfico va en HTTP plano — seguro en este contexto
porque ese tramo entero vive dentro de la red local del NAS, nunca
sale a internet. Ningún componente interno necesita certificado propio
ni gestionar renovación de TLS.
## Por qué HTTP/2 y no QUIC en el túnel
`cloudflared` puede usar QUIC (UDP) o HTTP/2 (TCP) para sus conexiones
salientes hacia el edge. Este lab fuerza HTTP/2 explícitamente — no es
el default. Un incidente real de `504 Gateway Timeout` intermitente en
**todos** los subdominios (no solo uno) se rastreó hasta fallos
recurrentes del "control stream" de QUIC sobre la red doméstica del
NAS. Ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)
para la decisión completa y el [playbook del 504](../playbooks/incidente-504-gitea-tunnel.md)
para el diagnóstico paso a paso.
## Patrón de diagnóstico: "interno sano, público roto"
Cuando un dominio público falla pero todo dentro del cluster responde
bien (pods `Running`, probes verdes, `kubectl exec` con `curl` al
Service funciona), la causa casi nunca está en Kubernetes — está en la
capa NPM. La forma rápida de confirmarlo es saltarse NPM a propósito:
`curl -H "Host: <dominio>" http://192.168.68.61:90/` habla directo con
`k3d-proxy`/Traefik sin pasar por NPM. Si eso funciona pero el dominio
público no, el problema está específicamente en el `forward_host`/
`forward_port` del proxy host en NPM (ej.: un scheme o un path colado
en `forward_host`, que rompe todo el enrutamiento).
Este patrón se repitió dos veces en este lab con la misma causa raíz
exacta: el [incidente de imágenes en `media.cruzcloud.net`](../playbooks/incidente-imagenes-npm.md)
(playbook completo) y un incidente posterior de `commerce.cruzcloud.net`
devolviendo 400 por el mismo tipo de `forward_host` roto (sin playbook
propio todavía). Una lección adicional de ese segundo incidente:
editar el `proxy_host` a mano en el sqlite de NPM **no** regenera el
`.conf` de nginx automáticamente — el cambio tiene que pasar por la
propia UI/API de NPM, o el `.conf` hay que parchearlo a mano y
recargar nginx.
@@ -1,10 +1,10 @@
# Visión general
> **TODO (fase 2+):** narrativa completa explicando cada capa del
> diagrama, con foco en el "por qué" de cada frontera de confianza. El
> diagrama de abajo es real (validado contra `docs/known-issues.md` y
> los playbooks de incidentes de `apps-registry`), pero el texto que lo
> acompaña es solo el esqueleto.
Este lab corre sobre un NAS ZimaOS doméstico, sin IP pública ni
presupuesto de nube — cada capa del diagrama de abajo existe por una
razón concreta de ese contexto, no por moda arquitectónica. Esta
página explica el "por qué" de cada frontera; el "cómo" vive en las
páginas enlazadas al final de cada sección.
## Flujo de una petición pública
@@ -40,4 +40,52 @@ flowchart LR
| Argo CD | Sincroniza el estado del cluster con Git (patrón App of Apps) | Namespace `argocd` |
| Gitea | Origen de verdad de los manifiestos y del código de las apps | Contenedor propio, expuesto vía túnel |
## Por qué cada frontera está donde está
**Cloudflare Tunnel, no port-forwarding.** El NAS vive detrás de NAT
residencial, sin IP pública fija. En vez de abrir puertos en el router
(superficie de ataque directa contra el hogar), `cloudflared` abre una
conexión **saliente** hacia Cloudflare y todo el tráfico público viaja
hacia adentro por esa misma conexión — el origen nunca expone un
puerto escuchando en internet. El transporte de esa conexión se fijó a
HTTP/2 en vez del QUIC por defecto tras un incidente real de
inestabilidad; ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md).
**Nginx Proxy Manager, fuera del cluster.** No todo lo que sirve
`*.cruzcloud.net` vive dentro de k3d — Gitea, por ejemplo, corre como
contenedor Docker aparte. NPM es el único punto de enrutamiento por
dominio que necesita conocer *ambos* mundos (cluster y no-cluster), así
que vive fuera de Kubernetes a propósito, no por descuido.
**`k3d-lab-cluster-serverlb`, la frontera de entrada al cluster.** Es
el load balancer que el propio k3d monta automáticamente para publicar
puertos del host hacia los nodos internos — no es un componente que
este lab configuró a mano, viene con la topología multi-nodo elegida
en [ADR 0001](../decisiones/0001-por-que-k3d.md).
**Traefik, enrutamiento por `Host` dentro del cluster.** Una vez el
tráfico entra al cluster por un único puerto (90/9443), Traefik es
quien decide a qué Service mandarlo según el header `Host` de cada
petición — así NPM solo necesita saber "todo `*.cruzcloud.net` va al
puerto 90 del cluster", sin tener que aprender un forward_host nuevo
cada vez que se agrega un servicio interno. Cuando esta capa falla en
silencio (`forward_host`/`forward_port` mal apuntado en NPM), el
síntoma es siempre el mismo: "internamente sano, públicamente roto" —
ver [Red y exposición](red-y-exposicion.md).
**Argo CD, el único escritor del cluster.** Ningún pipeline de CI ni
ninguna persona corre `kubectl apply` contra producción — Argo CD es
la única pieza con permisos de escritura, y reconstruye
automáticamente cualquier diferencia entre Git y el cluster real
(`selfHeal: true`). El porqué completo de esa elección está en
[ADR 0002](../decisiones/0002-por-que-argocd-vs-manual.md); el paso a
paso de un commit real hasta quedar corriendo, en
[Flujo GitOps](gitops-flujo.md).
**Gitea, autoalojado.** Además de alojar el código, Gitea corre el CI
(Gitea Actions) y el registry de imágenes en el mismo lugar — un solo
componente autoalojado en vez de depender de la disponibilidad y los
límites de un servicio externo gratuito para un lab que ya de por sí
no tiene presupuesto de nube.
Ver también: [Red y exposición](red-y-exposicion.md) · [Flujo GitOps](gitops-flujo.md) · [Glosario](glosario.md)
@@ -1,33 +1,67 @@
# ADR 0001 — Por qué k3d (y no k3s directo, minikube, o un cluster cloud)
> **TODO (fase 2+):** completar contexto/opciones/consecuencias con
> criterio real (recursos del NAS ZimaOS, necesidad de multi-nodo para
> reproducir el blackhole de MTU documentado en `known-issues.md`,
> costo cero vs. cluster gestionado).
**Estado:** aceptada (placeholder — pendiente de redactar)
**Fecha:** TODO
**Estado:** aceptada
**Fecha:** 2026-08 (retroactiva — decisión tomada al arrancar el lab, documentada en Fase 2 del portal)
## Contexto
TODO
El lab corre sobre un NAS ZimaOS doméstico, sin presupuesto para un
cluster gestionado y con recursos modestos y compartidos con otras
apps del NAS (Gitea, Nginx Proxy Manager, este mismo portal). El
propósito del lab es doble: servir de portafolio técnico real y de
material de estudio para el hijo del usuario (7mo semestre de
Ingeniería de Sistemas) — ver [guía del estudiante](../guia-estudiante/README.md).
Para que ambos objetivos tengan sentido, el cluster necesitaba
comportarse como un Kubernetes real y multi-nodo, no como una caja de
arena de un solo proceso: patrones como afinidad de pods, StatefulSets
con almacenamiento persistente por nodo, y tráfico cross-node debían
poder reproducirse y depurarse de verdad, no simularse.
También pesó la restricción del propio sistema operativo: `/` en
ZimaOS es un `squashfs` de solo lectura y `/usr` es un overlay
igualmente inmutable — instalar un binario nativo como `k3s` que
espera escribir en `/usr/local/bin`, `/etc/rancher`, etc. de forma
persistente no encaja bien con ese filesystem sin trabajo extra de
por medio.
## Opciones consideradas
| Opción | A favor | En contra |
|---|---|---|
| k3d | | |
| k3s directo (sin Docker) | | |
| minikube | | |
| Cluster gestionado (cloud) | | |
| **k3d** | Multi-nodo real corriendo como contenedores Docker (server + agent), se crea/destruye en segundos, no toca el filesystem inmutable de ZimaOS más allá de `/DATA` | Al ser Docker-in-Docker, hereda las capas de red de Docker — un blackhole de MTU/PMTUD real apareció por esto (ver Consecuencias) |
| k3s directo (sin Docker) | Sin la capa extra de red de Docker-in-Docker, más cercano a un nodo "real" | Necesita instalar servicios systemd y escribir en rutas de sistema (`/usr/local/bin`, `/etc/rancher`) que en ZimaOS son de solo lectura o requieren workarounds — solo `/DATA` y `/etc` quedan escribibles |
| minikube | Instalación simple, muy documentado, ideal para aprender lo básico | Single-node por defecto — no permite reproducir tráfico cross-node, afinidad de pods, ni el tipo de incidentes de infraestructura real que este lab busca documentar como aprendizaje |
| Cluster gestionado (cloud) | Cero mantenimiento de infraestructura, SLA real | Costo recurrente que este lab no tiene presupuestado; además reduce el valor de aprendizaje — gran parte de lo documentado en `playbooks/` (MTU, DNS, límites de recursos) es justamente lo que un proveedor gestionado abstrae |
## Decisión
TODO
k3d, multi-nodo (hoy: 1 server + 1 agent, `k3d-lab-cluster-server-0` /
`k3d-lab-cluster-agent-0`), corriendo como contenedores Docker sobre
el propio NAS.
## Consecuencias
TODO — mencionar explícitamente el blackhole de red cross-node
(MTU/PMTUD) como consecuencia real y documentada de correr multi-nodo
sobre Docker-in-Docker sin ajuste de MTU (ver
[playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md)).
- **Se ganó** un cluster real y gratuito, reproducible con un solo
script (`scripts/deploy-lab.sh`), y suficientemente parecido a un
Kubernetes de producción como para que los incidentes documentados
en `playbooks/` sean transferibles a un entorno cloud real.
- **Se pagó** con un blackhole de red cross-node real: cualquier pod
agendado en un nodo distinto al de `commerce-postgres-0`
(StatefulSet, fijo a un nodo) fallaba de forma consistente al hablar
con Postgres — diagnosticado hasta el bit exacto (payload interno
1420 bytes OK, 1450 bytes blackhole total, sin ICMP "frag needed")
y resuelto ajustando el MTU real de la interfaz `flannel.1` a 1400
en cada nodo. Ver [known-issues.md](https://gitea.cruzcloud.net/devops/apps-registry/src/branch/main/docs/known-issues.md)
y el [playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md)
para el diagnóstico completo. El fix no persiste solo — está
automatizado en `scripts/deploy-lab.sh` (función `fix_flannel_mtu`),
se reaplica cada vez que el cluster arranca o se recrea.
- **Deuda aceptada a propósito**: el `podAffinity` que fuerza a
`medusa-deploy` a compartir nodo con Postgres se dejó como red de
contención extra después del fix de fondo — no es estrictamente
necesario, pero es barato mantenerlo.
- **Para revertir esta decisión** haría falta migrar a un cluster
gestionado o a `k3s` nativo, lo que implicaría asumir el costo
recurrente (cloud) o resolver las restricciones de filesystem
inmutable de ZimaOS (k3s nativo) — ninguna de las dos se justifica
hoy con el tamaño real del lab.
@@ -1,30 +1,80 @@
# ADR 0002 — Por qué Argo CD (y no `kubectl apply` manual o un script de CI)
> **TODO (fase 2+):** completar con el criterio real detrás de elegir
> reconciliación continua (`selfHeal`) sobre despliegue imperativo desde
> CI.
**Estado:** aceptada (placeholder — pendiente de redactar)
**Fecha:** TODO
**Estado:** aceptada
**Fecha:** 2026-08 (retroactiva — decisión tomada al arrancar el lab, documentada en Fase 2 del portal)
## Contexto
TODO
Desde el principio el lab iba a tener más de un repo Git (hoy:
`apps-registry` para las Applications y los manifiestos reales de los
workloads, `platform-infra` para la gobernanza de cada namespace,
`scripts` para bootstrap/operación) y más de una app corriendo en el
cluster (ecommerce, docs-portal, nginx, cada una con su propia
gobernanza). Se necesitaba una forma de mantener el estado del
cluster sincronizado con lo declarado en Git que escalara a "agregar
una app nueva" sin volverse un proceso manual que dependiera de que
una sola persona recordara qué `kubectl apply` correr y en qué orden.
También importaba la trazabilidad: en un lab que sirve de portafolio,
poder señalar "este commit es exactamente lo que está corriendo en
producción" (y por qué) tiene valor por sí mismo — no solo operativo.
## Opciones consideradas
| Opción | A favor | En contra |
|---|---|---|
| Argo CD (GitOps, pull-based) | | |
| `kubectl apply` manual | | |
| `kubectl apply` desde el pipeline de CI (push-based) | | |
| **Argo CD (GitOps, pull-based)** | Reconciliación continua: un proceso dentro del cluster compara el estado real contra Git y corrige la diferencia solo, sin que nadie tenga que acordarse de aplicar nada. Patrón App of Apps nativo — agregar una app nueva es un archivo YAML más en `apps/`, no un paso manual nuevo | Componente extra que mantener sano (su propio namespace, su propio consumo de recursos) |
| `kubectl apply` manual | Cero infraestructura extra, control total en cada comando | Sin detección de drift — si alguien edita algo a mano y lo olvida, nadie se entera hasta que rompe; no escala más allá de un solo operador que recuerde el estado completo de memoria |
| `kubectl apply` desde el pipeline de CI (push-based) | No agrega un componente nuevo al cluster, reutiliza el CI que ya existe para construir imágenes | El pipeline de CI necesitaría credenciales de escritura amplias sobre el cluster — más superficie de riesgo si esas credenciales se filtran. Push-based además significa que nada vuelve a comparar el estado después de aplicado: un drift posterior (alguien con `kubectl` directo) queda invisible hasta el siguiente deploy |
## Decisión
TODO
Argo CD, patrón App of Apps: una Application raíz
(`apps-registry/application.yaml`) vigila la carpeta `apps/`, y cada
archivo ahí dentro es una Application hija que apunta a un workload
real. Toda Application se declara con el mismo `syncPolicy`:
```yaml
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
```
(configuración real de `apps/ecommerce-app.yaml`, repetida en cada
Application del repo)
## Consecuencias
TODO — mencionar el patrón App of Apps (`application.yaml` +
`apps/*.yaml`) y cómo `selfHeal:true` cambia el modelo mental de
"quién puede tocar el cluster".
- **`selfHeal: true` cambia el modelo mental de quién puede tocar el
cluster de verdad.** No es solo que Argo CD detecte diferencias y
las muestre como `OutOfSync` — las revierte activamente. Si alguien
corre `kubectl scale` o `kubectl edit` directo contra un recurso
gestionado, en el siguiente ciclo de reconciliación (poll periódico,
o inmediato si el commit llegó por webhook) Argo CD lo vuelve a
dejar exactamente como dice Git. Git deja de ser "la intención" y
pasa a ser literalmente la única fuente de la que el cluster puede
aprender su propio estado — ver [flujo GitOps](../arquitectura/gitops-flujo.md).
- **`prune: true`** significa que borrar un recurso del repo (por
ejemplo, quitar `meilisearch.yaml` de una `kustomization.yaml`) lo
borra también del cluster en el siguiente sync, sin un paso manual
de limpieza — confirmado en la práctica en varias fases de este lab
(ver los ADRs de rediseño del storefront y de Meilisearch).
- **El rol de CI se redujo a "construir, publicar imagen, y dejar un
commit en Git"** — nunca aplica nada directo al cluster. Esto evitó
tener que darle a CI credenciales de cluster, y dejó un problema real
documentado (la condición de carrera de promoción cuando dos
pipelines corren en serie sobre el mismo commit) resoluble
comparando contra Git (`git merge-base --is-ancestor`) en vez de
contra el estado del cluster.
- **Se paga con un componente más que mantener sano**: Argo CD corre
en su propio namespace, con su propio consumo de recursos y su
propia superficie de fallo — un Argo CD caído no rompe lo que ya
está corriendo (el cluster sigue con el último estado aplicado),
pero sí detiene la sincronización de cambios nuevos.
- **Para revertir esta decisión** haría falta volver a un modelo
push-based (CI aplicando directo) o puramente manual, perdiendo la
reconciliación continua y aceptando que un drift manual pueda pasar
desapercibido.
@@ -282,7 +282,7 @@ contra estos 9 productos demo:
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.
El usuario admin temporal `[email protected]` (creado para la
auditoría de la sección 5) fue borrado el 2026-08-27 vía Medusa Admin.
Sin próxima etapa definida todavía — este ADR no especifica una
Etapa 3.
@@ -295,3 +295,36 @@ desviaciones reales respecto al diseño de §4/§5 de este ADR:
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).
## Nota de implementación 2 (2026-08-26, gate en vivo de Fase 2)
Dos bugs reales encontrados y corregidos durante el gate de
verificación en vivo (crear/editar productos, categoría y colección
reales y confirmar que llegan a Meilisearch), ambos con la misma causa
raíz: `variants.calculated_price` de Medusa exige `region_id` **y**
`currency_code` resueltos en el contexto de pricing de la consulta, y
ninguno de los dos caminos nuevos de este ADR pasa por el middleware
nativo (`setPricingContext`) que se lo da gratis a `/store/products`:
1. El job de sync inicial del plugin (`sync-products.js`, corre una
vez al boot) hacía fallar el indexado de **todos** los productos
con `Method calculatePrices requires currency_code in the pricing
context` — las categorías sí indexaban bien (no llevan precio).
Arreglado indexando el precio crudo (`variants.prices`, sin
resolver promociones/reglas) en vez de `calculated_price` — alcanza
para el filtro/orden por precio de la búsqueda.
2. La ruta propia `/store/search` pegaba el mismo error al re-hidratar
productos vía `query.graph`. Pasar `region_id` solo no alcanzó;
hubo que resolver el `currency_code` real de la región con una
consulta adicional antes de armar el `QueryContext`.
Confirmado en vivo, con el fix aplicado: búsqueda por texto, facetas
sobre las 27 unidades del catálogo real, y precio calculado correcto
(`calculated_amount`/`currency_code`) en la respuesta de
`/store/search`. También confirmado en vivo que los tres eventos que
el ADR original marcaba como "verificar, no asumir" — `product.*`,
`product-category.*` y **`product-collection.updated`** (el que tenía
un reporte público de no disparar siempre) — sí sincronizan de forma
confiable en esta combinación exacta (Medusa 2.18.0 + plugin 1.4.1),
propagando un rename de categoría/colección al documento indexado en
menos de 10 segundos.
@@ -0,0 +1,252 @@
# ADR 0007 — Identidad visual propia para ARI Shopping
**Estado:** aceptada (2026-08-27, dirección corregida aprobada por el usuario)
**Fecha:** 2026-08-27
> **Corrección de dirección (2026-08-27):** la primera versión de este
> ADR proponía clonar la estética visual de Amazon (paleta ink/naranja,
> radios mínimos, densidad Amazon). El usuario corrigió el enfoque:
> **el objetivo no es imitar a Amazon, es superar sus patrones de UX
> con una identidad propia, distintiva y memorable** — se adoptan los
> *principios funcionales* que hacen que Amazon (y el mercado en
> general) funcionen bien, no su paleta ni su estética. Esta versión
> reemplaza la anterior por completo.
> Alcance (sin cambios): **solo visual** (paleta, tipografía, radios,
> densidad) — ARI Shopping sigue siendo la marca, el catálogo sigue
> siendo el mismo (juguetes, mochilas, regalos, ya multi-categoría). No
> se toca taxonomía, backend, ni la estructura de navegación ya
> construida en el ADR 0005 (header de 3 niveles, mega-menú,
> breadcrumb, conteos de filtro) — se re-expresa visualmente sobre esa
> misma estructura.
## 1. Resumen de la auditoría del estado actual
(Sin cambios respecto a la versión anterior — sigue siendo la
descripción real del código, no de la propuesta.)
El storefront hoy tiene una identidad visual deliberadamente lúdica,
tipo "juguetería", consistente en todos los componentes revisados
(`app/globals.css`, `components/ui/button.tsx`, `components/ui/badge.tsx`,
`components/layout/navbar.tsx`, `components/layout/footer.tsx`,
`components/product/product-card.tsx`, `app/page.tsx`):
- **Paleta**: tokens propios en `@theme` (`--color-ari-cyan: #06b6d4`,
`--color-ari-magenta: #ec4899`, `--color-ari-yellow: #fde047`) más
gradientes `cyan→violet→fuchsia` y `amber→yellow→lime` en barras,
botones y fondos de sección. Paleta arcoíris de alta saturación, no
una paleta de marca contenida.
- **Forma**: `rounded-full` en casi todo — botones, badges, chips,
avatar del logo, inputs del footer. Cero variación de forma.
- **Tipografía**: `font-black`/`font-extrabold` como default en
título, precio, botones y texto secundario — todo compite por
atención, nada queda como "de soporte".
- **La estructura ya toma prestados patrones de mercado** (ADR 0005,
Etapa 2 sub-fase 3): header de 3 niveles, mega-menú "Departamentos",
footer oscuro de 4 columnas, badges de producto, breadcrumb, conteos
de filtro. Esta propuesta **no rehace esa estructura** — cambia
cómo se ve, no cómo funciona.
## 2. Principios de UX a adoptar (funcionales, no estéticos)
Estos son patrones que funcionan en el mercado — Amazon es la
referencia más citable, pero el principio es lo que se adopta, nunca
su paleta ni su forma:
| Principio | Por qué funciona (evidencia del mercado) | Cómo se aplica en ARI sin copiar la estética |
|---|---|---|
| **Jerarquía visual clara** entre precio/CTA/información secundaria | Cuando todo pesa igual (todo bold, todo saturado), el ojo no sabe dónde mirar primero — el usuario tarda más en decidir | El precio y el botón de acción son los únicos elementos con peso tipográfico y color reservado; categoría/SKU/colección bajan a texto liviano y gris cálido |
| **Alta densidad de información sin sentirse saturado** | Más información útil visible (variantes, stock, rating) sin agrandar la card compite mejor que decoración vacía | Los gradientes decorativos actuales ocupan espacio visual sin aportar información — se liberan para mostrar más señales reales del producto |
| **Señales de confianza visibles** | Urgencia de inventario real, garantías, tiempos de envío — leídas como información seria, no decoración | Se conservan/refuerzan elementos ya reales del sitio ("Quedan N", `TrustBar` del home) expresados en colores serios, no en el arcoíris actual |
| **Contraste suficiente para escaneo rápido** | Un usuario escanea, no lee — necesita distinguir texto/fondo/CTA en milisegundos | Cada par de color de esta propuesta se valida contra WCAG AA (ver §4.1) antes de aprobarse, no se asume |
## 3. Por qué no clonar la estética (opciones consideradas)
| Opción | A favor | En contra |
|---|---|---|
| Clonar estética Amazon (ink/naranja/gris, radios mínimos) | Reconocible como "marketplace serio" de inmediato | Es exactamente lo que el usuario pidió evitar — ARI Shopping se vuelve indistinguible de cualquier clon genérico; cero valor de marca propio |
| **Identidad propia guiada por los principios de §2** (esta propuesta) | Distintiva, memorable, defendible como marca; resuelve los mismos problemas de UX reales del sitio actual sin perder personalidad | Requiere más trabajo de diseño que copiar un sistema ya hecho — hay que justificar cada decisión, no solo imitar |
| Mantener la paleta arcoíris actual, solo bajar el peso de fuente | Cambio mínimo | No resuelve el pedido real ("ya no la tienda de juguetes") — el problema no es solo la tipografía, es la paleta completa |
**Decisión de esta sección:** identidad propia.
## 4. Identidad visual propia propuesta
### 4.1 Paleta — "cálida, confiable, ligeramente lúdica"
Evita a propósito el azul corporativo genérico y el arcoíris actual.
Parte de la propia identidad narrativa del sitio ("tesoros para cada
aventura") — una paleta de tono *tienda de tesoros/regalos cálida*,
no de bazar infantil ni de marketplace frío.
| Token | Valor | Uso | Por qué esta elección |
|---|---|---|---|
| `--color-ari-terracota` | `#C1502E` | CTA primario ("Agregar al carrito", "Comprar") — único uso reservado a acción | Cálido y terroso, no un naranja saturado tipo Amazon ni un rosa/fucsia de juguetería. Al ser el *único* color reservado a acción, refuerza la jerarquía visual del §2 |
| `--color-ari-espresso` | `#2B1B13` | Header, footer, texto sobre fondo claro cuando se necesita máximo contraste | Ancla oscura cálida (marrón profundo), no el navy frío de Amazon — mismo rol funcional (contraste fuerte para escanear), estética propia |
| `--color-ari-pino` | `#2F6F62` | Links, estados de confirmación, acentos secundarios/confianza | Reemplaza al "azul de link" genérico sin perder la legibilidad de "esto es clickeable" — un verde-azulado profundo lee como serio y confiable sin ser corporativo |
| `--color-ari-crema` | `#FBF3E7` | Fondo general fuera de las cards | Blanco cálido, no gris frío (`#EAEDED` de Amazon) ni blanco puro — sensación de catálogo de regalos, no de bodega |
| `--color-ari-carbon` | `#2E2620` | Texto principal | Negro cálido, coherente con el resto de la paleta — nunca `slate` frío |
| `--color-ari-oro` | `#E3A008` | Badge "Destacado/Top" | Reemplaza el amarillo neón actual (`#fde047`) por un dorado más rico — mismo rol (llamar la atención puntual), menos "juguete plástico" |
| `--color-ari-ladrillo` | `#A83232` | Badge de descuento | Reemplaza rosa/fucsia — un rojo ladrillo serio comunica "oferta" sin verse infantil |
**Validación de contraste (WCAG AA, calculada, no asumida):**
| Par | Contraste | Cumple AA (texto normal ≥4.5:1 / texto grande ≥3:1) |
|---|---|---|
| Carbón sobre crema (texto de cuerpo) | 13.5:1 | Sí, con margen amplio (supera incluso AAA) |
| Blanco sobre espresso (header/footer) | 16.5:1 | Sí, con margen amplio |
| Blanco sobre terracota (texto de botón CTA) | 4.7:1 | Sí, cumple normal — margen ajustado, mantener el label del botón en `font-semibold` o superior |
| Pino sobre crema (links) | 5.3:1 | Sí, cumple normal |
### 4.2 Tipografía — con carácter propio, no la default de cualquier plantilla
- **Titulares y precio**: [Fraunces](https://fonts.google.com/specimen/Fraunces)
(serif cálida, con personalidad, cargable vía `next/font/google` sin
costo de licencia) — usada en h1/h2 de sección, en el precio grande
de producto, y en momentos de marca (hero). Un serif con carácter en
estos puntos específicos es una señal de marca deliberada: casi
ningún competidor de e-commerce usa serif, todos van 100% sans —
conecta directo con "tesoros para cada aventura" (sensación de
catálogo curado, no de bazar).
- **UI y cuerpo**: se mantiene **Inter** (ya integrada, probada, muy
legible en tamaños chicos — nav, badges, filtros) pero se abandona
el default `font-black`/`font-extrabold` en todo. Escala real:
`font-normal` para texto de soporte, `font-semibold` para
título/etiqueta de card, `font-bold` reservado a precio y CTA
únicamente — el peso vuelve a significar algo en vez de ser el
default de todo.
### 4.3 Lenguaje de forma — reconocible sin el logo
- **Radio uniforme**: `rounded-xl` (12px) en botones, cards, inputs y
badges — ni el `rounded-full` actual (se siente "globo de juguete"),
ni el radio mínimo tipo Amazon (se siente frío/utilitario). Un radio
moderado y consistente en todo el sistema lee como diseño
deliberado, no como plantilla default.
- **Sombras con tinte de marca**: sombras suaves calculadas sobre
`--color-ari-espresso` a baja opacidad (ej.
`shadow-[0_8px_24px_-8px_rgba(43,27,19,0.25)]`) en vez del
`shadow-slate-900/…` genérico actual — un detalle que casi nadie
hace (la mayoría usa el gris default del framework) y que ata la
sombra misma a la paleta de marca.
- **Densidad**: intermedia — ni tan aireada/decorativa como hoy (mucho
espacio ocupado por gradientes y texto sobre-dimensionado) ni tan
comprimida como un marketplace utilitario.
### 4.4 Tres decisiones deliberadas y distintivas
Elegidas específicamente para ser difíciles de replicar "por
accidente" por un competidor genérico:
1. **Badge en forma de "etiqueta de regalo"**: los badges de
producto (Nuevo/Top/Descuento) dejan de ser una píldora
`rounded-full` genérica y pasan a una forma con una pequeña
muesca en una esquina (`clip-path` CSS), evocando una etiqueta de
precio/regalo física — coherente con "tienda de regalos", y no es
una utilidad default de Tailwind, así que no aparece por accidente
en ningún template genérico.
2. **Fondo crema + viñeta suave en fotografía de producto**: las
imágenes de producto se apoyan sobre `--color-ari-crema` (no blanco
ni gris) con una viñeta radial muy sutil — el catálogo se siente
como una vitrina de regalos curada, no una grilla de bodega.
3. **Micro-interacción de marca en el CTA principal**: al hacer hover/
focus en "Agregar al carrito", el ícono de bolsa hace un pequeño
giro/rebote de ~200ms (CSS puro, sin JS) en vez del `-translate-y`
genérico actual — reservado *solo* al CTA principal, para que el
momento de acción tenga una firma reconocible sin volverse
caricaturesco en el resto del sitio.
## 5. Tabla de tokens: antes → después
| Token / propiedad | Antes | Después (propuesto) |
|---|---|---|
| `--color-ari-cyan` (`#06b6d4`) | color de marca | eliminado |
| `--color-ari-magenta` (`#ec4899`) | color de marca/CTA | eliminado |
| `--color-ari-yellow` (`#fde047`) | acento | eliminado, ver `--color-ari-oro` |
| Nuevo: `--color-ari-terracota` | — | `#C1502E` — CTA único |
| Nuevo: `--color-ari-espresso` | — | `#2B1B13` — header/footer |
| Nuevo: `--color-ari-pino` | — | `#2F6F62` — links/confianza |
| Nuevo: `--color-ari-crema` | — | `#FBF3E7` — fondo general |
| Nuevo: `--color-ari-carbon` | — | `#2E2620` — texto principal |
| Nuevo: `--color-ari-oro` | — | `#E3A008` — badge destacado |
| Nuevo: `--color-ari-ladrillo` | — | `#A83232` — badge descuento |
| Radio por defecto | `rounded-full` | `rounded-xl` (uniforme) |
| Radio de card de producto | `rounded-3xl` | `rounded-xl` |
| Peso de fuente por defecto | `font-black`/`font-extrabold` en todo | `font-normal` (soporte) / `font-semibold` (títulos/labels) / `font-bold` (solo precio y CTA) |
| Fuente de titulares/precio | Inter (heredada del body) | Fraunces |
| Fondo de sección | `#fff` | `--color-ari-crema` fuera de cards, blanco dentro |
| Sombras | `shadow-slate-900/…` genérico | tintado con `--color-ari-espresso` |
| Gradientes decorativos | `cyan/violet/fuchsia`, `amber/yellow/lime` | eliminados — bloques de color sólido |
## 6. Wireframe en texto: header (antes/después)
**Antes:**
```
[ barra degradada ámbar/amarillo: "Paga a cuotas · Envíos..." ]
[ logo redondo | buscador (gradiente cyan/violeta/fuchsia) | cuenta ♡ 🛒(amarillo) ]
[ barra negra: ☰ Departamentos▾(fuchsia al activo) | Todo | ... ]
```
**Después (propuesto):**
```
[ barra espresso fina, sólida: "Envíos nacionales · Compra segura con ARI" ]
[ fondo espresso | logo | buscador (input crema, botón terracota) | cuenta ♡ 🛒(badge terracota) ]
[ barra espresso: ☰ Departamentos▾(terracota al activo) | Todo | ... ]
```
## 7. Wireframe en texto: product card (antes/después)
**Antes**: `rounded-3xl`, sombra genérica grande, badges en rosa/
fucsia/amarillo, botón "Agregar" en gradiente fucsia/pink.
**Después:** `rounded-xl`, sombra tintada espresso a baja opacidad,
fondo crema detrás de la foto con viñeta sutil, badge "etiqueta de
regalo" (ladrillo para descuento, oro para destacado), precio en
Fraunces bold sobre carbón, botón "Agregar" sólido terracota con la
micro-interacción de §4.4.3 al hover.
## 8. Decisión
Identidad visual propia (§4), ejecutada en las mismas 5 sub-fases
independientes ya planteadas — cada una su propio `fix/*`, diff
mostrado, gate visual antes de la siguiente:
1. **Tokens base**: `globals.css` (`@theme`, variables centralizadas —
nada de valores hex sueltos en componentes), `button.tsx` (nueva
variant `primary` en terracota), `badge.tsx`, integración de
Fraunces vía `next/font/google`.
2. **Header + footer**.
3. **Product card + grid de catálogo**.
4. **Home**.
5. **Página de producto + drawers de carrito/wishlist**.
**Validación de la Sub-fase 1 antes de tocar todo lo demás:**
aplicar los tokens nuevos a un único elemento visible (ej. el botón
CTA de un solo componente) como *smoke test* visual antes de propagar
el cambio a todo el sistema — y, dado que el flujo de revalidación
Admin → `product.updated` → `revalidateTag` (protegido desde Fase 5,
ver ADR 0005) no debería verse afectado por un cambio puramente de
estilos, una edición real de producto vía Admin sirve como validación
cruzada de que nada de esto tocó ese flujo. Mismo mecanismo de
rama/diff/confirmación de siempre en cada paso.
## 9. Consecuencias
- Se gana una identidad de marca propia, memorable y justificable
componente por componente — no una imitación de ningún competidor
ni la continuación de la paleta arcoíris actual.
- Sigue siendo un cambio visible en todas las páginas del storefront a
la vez conceptualmente, pero se implementa y valida en 5 sub-fases
independientes, cada una revertible por separado (`git revert` del
commit de esa sub-fase — no hay manifiestos de por medio, es código
de frontend puro).
- El catálogo, backend, taxonomía y flujo de búsqueda/facetas (ADR
0006) quedan completamente intactos.
- Trabajo puramente de frontend: no requiere cambios en
`commerce-backend`, `platform-infra`, ni CI nuevo — reutiliza
`build.yaml` tal como está.
- Costo adicional real frente a un clon de Amazon: cada decisión de
esta propuesta tuvo que justificarse contra un principio de UX en
vez de copiarse — más trabajo de diseño ahora, pero es lo que hace
que el resultado sea defendible como marca propia y no un genérico
reconocible.
+1
View File
@@ -93,6 +93,7 @@ nav:
- "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
- "0007 · Identidad visual propia": decisiones/0007-identidad-visual-propia.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.164
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.175
# El base saca TODAS las capabilities. Probado en vivo y
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
@@ -0,0 +1,45 @@
import { searchWithFacets } from "@/lib/headless/client";
import type { ProductFilters } from "@/lib/headless/types";
// PredictiveSearch es un componente cliente y no puede pegarle directo
// al provider server-side (MEDUSA_BACKEND_URL es un nombre DNS interno
// del cluster, no resuelve desde el navegador) -- esta ruta es el
// puente same-origin.
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const filters: ProductFilters = {
query: searchParams.get("q") ?? undefined,
category: searchParams.get("category") ?? undefined,
collection: searchParams.get("collection") ?? undefined,
brand: searchParams.get("brand") ?? undefined,
ageRange: searchParams.get("ageRange") ?? undefined,
};
const minPrice = searchParams.get("minPrice");
if (minPrice) filters.minPrice = Number(minPrice);
const maxPrice = searchParams.get("maxPrice");
if (maxPrice) filters.maxPrice = Number(maxPrice);
const limit = searchParams.get("limit");
if (limit) filters.limit = Number(limit);
try {
const result = await searchWithFacets(filters);
// provider actual no implementa searchWithFacets (mock/shopify) --
// degradar a vacío, no a 500; el componente cliente cae de nuevo a
// su propio filtrado si esta ruta no trae nada útil.
return Response.json(
result ?? { products: [], facetDistribution: {}, count: 0 },
);
} catch (error) {
return Response.json(
{
products: [],
facetDistribution: {},
count: 0,
error: (error as Error).message,
},
{ status: 502 },
);
}
}
@@ -65,7 +65,7 @@ spec:
memory: 64Mi
- name: migrations
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.165
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
imagePullPolicy: IfNotPresent
command:
- node
@@ -86,7 +86,7 @@ spec:
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.165
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
@@ -7,7 +7,7 @@ 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 { Category, Product } from "@/lib/headless/types";
import type { Category, FacetDistribution, 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
@@ -156,8 +156,10 @@ export function CatalogClient({
// 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(() => {
// resultados habria si se eligiera ese valor. Sirven de fallback cuando
// el provider activo no soporta facetas reales (mock/shopify) o cuando
// la busqueda en vivo todavia no respondio.
const fallbackCategoryCounts = useMemo(() => {
const base = products.filter(
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice),
);
@@ -170,7 +172,7 @@ export function CatalogClient({
return counts;
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
const brandCounts = useMemo(() => {
const fallbackBrandCounts = useMemo(() => {
const base = products.filter(
(product) =>
matchesQuery(product, query) &&
@@ -187,7 +189,7 @@ export function CatalogClient({
return counts;
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
const ageCounts = useMemo(() => {
const fallbackAgeCounts = useMemo(() => {
const base = products.filter(
(product) =>
matchesQuery(product, query) &&
@@ -204,6 +206,108 @@ export function CatalogClient({
return counts;
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]);
// "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe
// como valor indexado en Meilisearch (ningun producto lo tiene como
// category real, solo sus hijos -- ver categoryGroups arriba). Mientras
// esa sea la categoria activa, las facetas en vivo de marca/edad no
// pueden filtrar por ella y se degrada al conteo client-side, que si
// sabe expandir el grupo via matchesCategory/allowedCategories.
const categoryIsLeafOrNone = category === "Todos" || !categoryGroups[category];
// Conteos reales via Meilisearch (ADR 0006 Etapa 2 Fase 3b): un fetch
// por dimension contra /api/search, cada uno omitiendo su propio filtro
// (mismo criterio de auto-exclusion que el fallback de arriba) para que
// los conteos reflejen el subconjunto ya filtrado por las OTRAS
// dimensiones y se puedan seguir eligiendo otros valores. `limit=0` evita
// rehidratar precios de productos que no vamos a mostrar -- solo importan
// `facetDistribution` y `count`.
const [liveFacets, setLiveFacets] = useState<{
category: Record<string, number> | null;
categoryTotal: number | null;
brand: Record<string, number> | null;
brandTotal: number | null;
ageRange: Record<string, number> | null;
ageTotal: number | null;
}>({ category: null, categoryTotal: null, brand: null, brandTotal: null, ageRange: null, ageTotal: null });
useEffect(() => {
const controller = new AbortController();
function buildParams(omit: "category" | "brand" | "ageRange"): URLSearchParams {
const params = new URLSearchParams({ limit: "0" });
if (query) params.set("q", query);
if (collection) params.set("collection", collection);
if (omit !== "category" && category !== "Todos" && categoryIsLeafOrNone) {
params.set("category", category);
}
if (omit !== "brand" && brand !== "Todas") params.set("brand", brand);
if (omit !== "ageRange" && age !== "Todas") params.set("ageRange", age);
if (maxPrice > 0) params.set("maxPrice", String(maxPrice));
return params;
}
async function fetchFacet(omit: "category" | "brand" | "ageRange") {
const response = await fetch(`/api/search?${buildParams(omit).toString()}`, {
signal: controller.signal,
});
return (await response.json()) as { facetDistribution?: FacetDistribution };
}
// El "count"/estimatedTotalHits que devuelve /api/search viene en 0 con
// limit=0 (confirmado en vivo contra Meilisearch real -- limit>=1 lo
// reporta bien, limit=0 no). facetDistribution si es correcto con
// limit=0, y cada producto tiene siempre un valor para cada dimension
// (mapProduct usa defaults como "ARI Shopping"/"Todas", nunca null), asi
// que sumar sus valores da el total real sin depender de ese campo.
function sumFacetValues(facet: Record<string, number> | null): number {
return facet ? Object.values(facet).reduce((sum, value) => sum + value, 0) : 0;
}
const timer = setTimeout(() => {
Promise.all([fetchFacet("category"), fetchFacet("brand"), fetchFacet("ageRange")])
.then(([categoryRes, brandRes, ageRes]) => {
// Un facetDistribution vacio ({}) es la senal de degrade explicita
// de /api/search (provider sin searchWithFacets, o Meilisearch
// sin indices todavia) -- distinto de una faceta real con 0
// resultados, que igual trae la clave presente.
const categoryFacet = categoryRes.facetDistribution && "category" in categoryRes.facetDistribution ? categoryRes.facetDistribution.category : null;
const brandFacet = brandRes.facetDistribution && "brand" in brandRes.facetDistribution ? brandRes.facetDistribution.brand : null;
const ageFacet = ageRes.facetDistribution && "ageRange" in ageRes.facetDistribution ? ageRes.facetDistribution.ageRange : null;
setLiveFacets({
category: categoryFacet,
categoryTotal: categoryFacet ? sumFacetValues(categoryFacet) : null,
brand: categoryIsLeafOrNone ? brandFacet : null,
brandTotal: categoryIsLeafOrNone && brandFacet ? sumFacetValues(brandFacet) : null,
ageRange: categoryIsLeafOrNone ? ageFacet : null,
ageTotal: categoryIsLeafOrNone && ageFacet ? sumFacetValues(ageFacet) : null,
});
})
.catch((error) => {
if ((error as Error).name !== "AbortError") {
setLiveFacets({ category: null, categoryTotal: null, brand: null, brandTotal: null, ageRange: null, ageTotal: null });
}
});
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]);
const categoryCounts = useMemo(
() => (liveFacets.category ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal],
);
const brandCounts = useMemo(
() => (liveFacets.brand ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal],
);
const ageCounts = useMemo(
() => (liveFacets.ageRange ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
[fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal],
);
const reset = () => {
setQuery("");
setCategory("Todos");
@@ -56,7 +56,7 @@ export function Navbar({ products, categories }: { products: Product[]; categori
<Link href="/" className="relative size-16 overflow-hidden rounded-full border-2 border-slate-900 bg-black shadow-lg lg:size-20">
<Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill priority sizes="80px" className="object-cover" />
</Link>
<PredictiveSearch products={products} />
<PredictiveSearch />
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" aria-label="Mi cuenta" className="hidden border-slate-300 bg-white text-slate-900 hover:border-violet-400 hover:bg-violet-50 sm:inline-flex" onClick={() => setAccountOpen(true)}><UserRound /></Button>
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-fuchsia-400 hover:bg-fuchsia-50 sm:inline-flex" onClick={openWishlist}>
@@ -4,20 +4,45 @@ import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Search, X } from "lucide-react";
import { FormEvent, useMemo, useState } from "react";
import { FormEvent, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import type { Product } from "@/lib/headless/types";
export function PredictiveSearch({ products }: { products: Product[] }) {
export function PredictiveSearch() {
const router = useRouter();
const [query, setQuery] = useState("");
const normalized = query.trim().toLocaleLowerCase("es");
const matches = useMemo(
() => normalized.length < 2
? []
: products.filter((product) => [product.name, product.category, product.collection, product.brand].join(" ").toLocaleLowerCase("es").includes(normalized)).slice(0, 6),
[normalized, products],
);
const [matches, setMatches] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
const normalized = query.trim();
useEffect(() => {
if (normalized.length < 2) {
setMatches([]);
setLoading(false);
return;
}
const controller = new AbortController();
setLoading(true);
const timer = setTimeout(() => {
fetch(`/api/search?q=${encodeURIComponent(normalized)}`, {
signal: controller.signal,
})
.then((response) => response.json())
.then((data: { products?: Product[] }) => {
setMatches((data.products ?? []).slice(0, 6));
})
.catch((error) => {
if ((error as Error).name !== "AbortError") setMatches([]);
})
.finally(() => setLoading(false));
}, 250);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [normalized]);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -36,6 +61,11 @@ export function PredictiveSearch({ products }: { products: Product[] }) {
{query && <button type="button" aria-label="Limpiar búsqueda" className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 hover:bg-slate-100" onClick={() => setQuery("")}><X className="size-4" /></button>}
</div>
</div>
{loading && normalized.length >= 2 && matches.length === 0 && (
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 rounded-2xl border border-slate-200 bg-white p-4 text-center text-sm text-slate-500 shadow-2xl">
Buscando…
</div>
)}
{matches.length > 0 && (
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 overflow-hidden rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl">
{matches.map((product) => (
@@ -25,7 +25,7 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.157
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.172
env:
- name: HEADLESS_PROVIDER
value: medusa
@@ -26,3 +26,7 @@ export async function searchProducts(query: string, limit?: number) {
export async function getCategories() {
return provider().getCategories();
}
export async function searchWithFacets(filters?: ProductFilters) {
return provider().searchWithFacets?.(filters);
}
@@ -1,6 +1,8 @@
import type {
CatalogProvider,
Category,
FacetDistribution,
FacetedSearchResult,
Product,
ProductFilters,
ProductVariant,
@@ -385,6 +387,56 @@ async function fetchCategories(): Promise<Category[]> {
}));
}
type MedusaSearchResponse = {
products: MedusaProduct[];
facetDistribution?: FacetDistribution;
count?: number;
};
// Pega contra /store/search (ADR 0006 Etapa 2) en vez de /store/products
// -- Meilisearch resuelve texto tolerante a errores de tipeo y facetas
// reales, la ruta del backend re-hidrata precio/inventario vía
// query.graph antes de responder, así que el shape de MedusaProduct es
// exactamente el mismo que ya mapea mapProduct().
async function fetchWithFacets(
filters?: ProductFilters,
limit = 24,
): Promise<FacetedSearchResult> {
const params = new URLSearchParams({
limit: String(limit),
region_id: regionId(),
});
if (filters?.query) params.set("q", filters.query);
if (filters?.category && filters.category !== "Todos") {
params.set("category", filters.category);
}
if (filters?.collection) params.set("collection", filters.collection);
if (filters?.brand && filters.brand !== "Todas") {
params.set("brand", filters.brand);
}
if (filters?.ageRange && filters.ageRange !== "Todas") {
params.set("ageRange", filters.ageRange);
}
if (typeof filters?.minPrice === "number") {
params.set("minPrice", String(filters.minPrice));
}
if (typeof filters?.maxPrice === "number") {
params.set("maxPrice", String(filters.maxPrice));
}
const response = await medusaFetch<MedusaSearchResponse>(
"/store/search",
params,
);
return {
products: response.products.map(mapProduct),
facetDistribution: response.facetDistribution ?? {},
count: response.count ?? response.products.length,
};
}
export const medusaCatalogProvider: CatalogProvider = {
async getProducts(filters) {
return fetchProducts(filters);
@@ -416,6 +468,11 @@ export const medusaCatalogProvider: CatalogProvider = {
},
async searchProducts(query, limit = 8) {
return fetchProducts({ query }, limit);
const { products } = await fetchWithFacets({ query }, limit);
return products;
},
async searchWithFacets(filters) {
return fetchWithFacets(filters, filters?.limit ?? 24);
},
};
+18
View File
@@ -38,6 +38,10 @@ export interface ProductFilters {
ageRange?: string;
minPrice?: number;
maxPrice?: number;
// Solo lo usa searchWithFacets -- limit=0 pide facetDistribution/count
// sin rehidratar precios de productos que no se van a mostrar (ver
// sidebar de facetas en catalog-client.tsx).
limit?: number;
}
export interface Category {
@@ -47,9 +51,23 @@ export interface Category {
parentId: string | null;
}
// facet -> valor -> cuantos productos del resultado filtrado tienen ese
// valor (ej. { brand: { Marvel: 5, Disney: 10 } }).
export type FacetDistribution = Record<string, Record<string, number>>;
export interface FacetedSearchResult {
products: Product[];
facetDistribution: FacetDistribution;
count: number;
}
export interface CatalogProvider {
getProducts(filters?: ProductFilters): Promise<Product[]>;
getProductBySlug(slug: string): Promise<Product | null>;
searchProducts(query: string, limit?: number): Promise<Product[]>;
getCategories(): Promise<Category[]>;
// Opcional a propósito: mock.ts y shopify.ts no lo implementan y
// siguen compilando igual -- los llamadores caen de nuevo al
// filtrado/conteo 100% client-side que ya existe cuando no está.
searchWithFacets?(filters?: ProductFilters): Promise<FacetedSearchResult>;
}
@@ -1,21 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend-deploy
spec:
template:
spec:
containers:
- name: web
env:
- name: HEADLESS_PROVIDER
value: medusa
- name: MEDUSA_BACKEND_URL
value: http://medusa-svc:9000
- name: MEDUSA_PUBLISHABLE_KEY
valueFrom:
secretKeyRef:
name: commerce-storefront
key: MEDUSA_PUBLISHABLE_KEY