Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52ac97c3e1 | ||
|
|
45d8152317 | ||
|
|
68adb32663 | ||
|
|
f761844cd0 | ||
|
|
31fd800734 | ||
|
|
9da759617c | ||
|
|
949788cda7 | ||
|
|
056a7fb76a | ||
|
|
b7443a034f | ||
|
|
4ee892a43a | ||
|
|
c6c66b76a3 | ||
|
|
7406aff34f | ||
|
|
ba3e6c7322 | ||
|
|
884dbd1d93 | ||
|
|
951682a4d4 | ||
|
|
6b5450f16f | ||
|
|
83d448b282 | ||
|
|
1ecd2015a4 | ||
|
|
609fc19252 | ||
|
|
7d2c03f10f |
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,8 @@ export async function GET(request: Request) {
|
||||
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);
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-ari-cyan: #06b6d4;
|
||||
--color-ari-magenta: #ec4899;
|
||||
--color-ari-yellow: #fde047;
|
||||
--color-ari-blue: #0f172a;
|
||||
/* ADR 0007 -- identidad visual propia (no derivada de ningun
|
||||
competidor). Cada token tiene un rol unico y deliberado, ver
|
||||
docs-portal/docs/decisiones/0007-identidad-visual-propia.md */
|
||||
--color-ari-terracota: #C1502E; /* CTA -- unico uso reservado a accion */
|
||||
--color-ari-espresso: #2B1B13; /* ancla oscura: header/footer */
|
||||
--color-ari-pino: #2F6F62; /* links / senales de confianza */
|
||||
--color-ari-crema: #FBF3E7; /* fondo general */
|
||||
--color-ari-carbon: #2E2620; /* texto principal */
|
||||
--color-ari-oro: #E3A008; /* badge "destacado" */
|
||||
--color-ari-ladrillo: #A83232; /* badge de descuento */
|
||||
--font-display: var(--font-fraunces), ui-serif, Georgia, serif;
|
||||
--animate-accordion-down: accordion-down .2s ease-out;
|
||||
--animate-accordion-up: accordion-up .2s ease-out;
|
||||
}
|
||||
@@ -15,10 +22,10 @@
|
||||
:root { color-scheme: light; }
|
||||
* { border-color: rgb(226 232 240); }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; background: #fff; color: #0f172a; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-rendering: optimizeLegibility; }
|
||||
body { margin: 0; background: var(--color-ari-crema); color: var(--color-ari-carbon); font-family: var(--font-inter), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-rendering: optimizeLegibility; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid rgb(167 139 250 / .55); outline-offset: 3px; }
|
||||
::selection { background: #fde047; color: #0f172a; }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid rgb(47 111 98 / .45); outline-offset: 3px; }
|
||||
::selection { background: rgb(193 80 46 / .25); color: var(--color-ari-carbon); }
|
||||
|
||||
.content-auto {
|
||||
content-visibility: auto;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Fraunces, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { CartDrawer } from "@/components/cart/cart-drawer";
|
||||
import { Footer } from "@/components/layout/footer";
|
||||
@@ -20,9 +21,15 @@ export const metadata: Metadata = {
|
||||
openGraph: { title: "ARI Shopping", description: "Tesoros para cada aventura.", type: "website", locale: "es_CO" },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = { width: "device-width", initialScale: 1, themeColor: "#0f172a" };
|
||||
export const viewport: Viewport = { width: "device-width", initialScale: 1, themeColor: "#2B1B13" };
|
||||
|
||||
// ADR 0007: Fraunces (titulares/precio) + Inter (UI/cuerpo) cargadas via
|
||||
// next/font -- antes "Inter" era solo un nombre de fallback en el CSS, sin
|
||||
// garantia real de que el navegador tuviera esa fuente instalada.
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
|
||||
const fraunces = Fraunces({ subsets: ["latin"], variable: "--font-fraunces", display: "swap" });
|
||||
|
||||
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
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>;
|
||||
return <html lang="es" className={`${inter.variable} ${fraunces.variable}`}><body><Navbar products={products} categories={categories} /><main>{children}</main><Footer /><CartDrawer /><WishlistDrawer /></body></html>;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -70,9 +70,11 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
||||
<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>
|
||||
{/* ADR 0007: smoke test de font-display (Fraunces) en el precio -- si se aprueba visualmente, se propaga al resto de precios en Sub-fase 3. */}
|
||||
<strong className="font-display text-base font-bold text-ari-carbon">{formatMoney(product.price, product.currency)}</strong>
|
||||
</div>
|
||||
<Button variant="magenta" size="sm" onClick={() => addItem(product, selectedVariantId)}><ShoppingBag className="size-4" />Agregar</Button>
|
||||
{/* ADR 0007: smoke test de la variant "primary" (terracota) -- si se aprueba visualmente, reemplaza "magenta" en el resto del sitio en Sub-fases 2-5. */}
|
||||
<Button variant="primary" size="sm" onClick={() => addItem(product, selectedVariantId)}><ShoppingBag className="size-4" />Agregar</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -9,6 +9,11 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-slate-950 text-white shadow-lg hover:-translate-y-0.5 hover:bg-slate-800",
|
||||
// ADR 0007: unico color reservado a accion (nunca decorativo) --
|
||||
// reemplaza gradualmente a "magenta" component por component
|
||||
// (sub-fases 2-5), empezando por el CTA de ProductCard como
|
||||
// smoke test visual antes de propagar al resto del sistema.
|
||||
primary: "bg-ari-terracota text-white shadow-lg shadow-ari-terracota/25 hover:brightness-95",
|
||||
magenta: "bg-gradient-to-r from-fuchsia-500 to-pink-500 text-white shadow-lg shadow-pink-500/25 hover:-translate-y-0.5",
|
||||
yellow: "bg-yellow-300 text-slate-950 shadow-lg shadow-yellow-400/25 hover:-translate-y-0.5 hover:bg-yellow-200",
|
||||
outline: "border border-slate-200 bg-white text-slate-950 hover:border-cyan-400 hover:bg-cyan-50",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -473,6 +473,6 @@ export const medusaCatalogProvider: CatalogProvider = {
|
||||
},
|
||||
|
||||
async searchWithFacets(filters) {
|
||||
return fetchWithFacets(filters);
|
||||
return fetchWithFacets(filters, filters?.limit ?? 24);
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user