Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
699598ab06 | ||
|
|
ef16858d35 | ||
|
|
9c11e16779 | ||
|
|
52ac97c3e1 | ||
|
|
45d8152317 | ||
|
|
68adb32663 | ||
|
|
f761844cd0 | ||
|
|
31fd800734 | ||
|
|
9da759617c | ||
|
|
949788cda7 | ||
|
|
056a7fb76a | ||
|
|
b7443a034f | ||
|
|
4ee892a43a | ||
|
|
c6c66b76a3 | ||
|
|
7406aff34f | ||
|
|
ba3e6c7322 | ||
|
|
884dbd1d93 | ||
|
|
951682a4d4 | ||
|
|
6b5450f16f | ||
|
|
83d448b282 | ||
|
|
1ecd2015a4 | ||
|
|
609fc19252 | ||
|
|
902350b58b | ||
|
|
7d2c03f10f | ||
|
|
72ae63f2e3 | ||
|
|
4afc63bdc6 | ||
|
|
ed9891577f | ||
|
|
41c7ad25e7 | ||
|
|
f2377be69c |
No files matched your search
@@ -1,20 +1,9 @@
|
|||||||
# Notas sueltas / aprendizajes
|
# Notas sueltas / aprendizajes
|
||||||
|
|
||||||
> **TODO (fase 2+):** esto es un scratchpad estructurado, no un
|
> Esto es un scratchpad estructurado, no un playbook — sigue creciendo
|
||||||
> playbook — va creciendo con notas cortas a medida que aparecen. Las
|
> con notas cortas a medida que aparecen. Cada entrada está extraída de
|
||||||
> entradas de abajo son semillas reales extraídas de incidentes ya
|
> un incidente o hallazgo real ya documentado en otra parte del sitio,
|
||||||
> documentados; expandir cada una con más contexto en fases siguientes.
|
> no es especulación.
|
||||||
|
|
||||||
## 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).
|
|
||||||
|
|
||||||
## Gotchas de Medusa v2
|
## Gotchas de Medusa v2
|
||||||
|
|
||||||
@@ -26,8 +15,70 @@
|
|||||||
del array `images` — hay que setearlo explícito desde el Admin si el
|
del array `images` — hay que setearlo explícito desde el Admin si el
|
||||||
frontend lo usa como imagen hero en listados.
|
frontend lo usa como imagen hero en listados.
|
||||||
|
|
||||||
## TODO
|
## MinIO / S3 provider
|
||||||
|
|
||||||
- Notas sobre MinIO / S3 provider.
|
- Medusa sube imágenes de producto a MinIO (`minio-svc:9000`, bucket
|
||||||
- Notas sobre el subscriber `revalidate-storefront.ts` y sus límites.
|
`ari-products`) usando el provider S3-compatible estándar — MinIO no
|
||||||
- Qué automatizaría si volviera a montar este lab desde cero.
|
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
|
# Del commit al despliegue: flujo GitOps
|
||||||
|
|
||||||
> **TODO (fase 2+):** narrar cada paso con ejemplos reales tomados de
|
El diagrama de abajo refleja el pipeline real (build → push imagen →
|
||||||
> `.gitea/workflows/build-medusa.yaml` y `build.yaml`. El diagrama de
|
promoción segura → commit del tag → sync de Argo CD). Después del
|
||||||
> secuencia de abajo refleja el pipeline real (build → push imagen →
|
diagrama hay un ejemplo con hashes de commit reales, tomado de un
|
||||||
> promoción segura → commit del tag → sync de Argo CD).
|
cambio real hecho sobre este mismo repo.
|
||||||
|
|
||||||
## Secuencia: un commit hasta quedar corriendo en el cluster
|
## 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
|
evita que un pipeline viejo sobreescriba el tag con una versión
|
||||||
anterior si hubo un push más reciente mientras corría.
|
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
|
Fase 3b del ADR 0006 (facetas del sidebar del catálogo, ver
|
||||||
`apps-registry`).
|
`decisiones/0006-motor-busqueda-facetas.md`) recorrió el flujo
|
||||||
- Explicar `syncPolicy.automated.selfHeal` con un caso concreto (qué
|
completo dos veces seguidas el mismo día:
|
||||||
pasa si alguien hace `kubectl edit` a mano).
|
|
||||||
|
| 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
|
incluir y qué parches aplicarles, sin necesidad de un lenguaje de
|
||||||
templating separado.
|
templating separado.
|
||||||
|
|
||||||
## TODO
|
## Namespace
|
||||||
|
|
||||||
- Namespace, ResourceQuota, LimitRange, NetworkPolicy
|
**En una frase:** una partición lógica dentro del mismo cluster físico
|
||||||
- Ingress vs. Service vs. Deployment
|
— agrupa recursos relacionados y les pone un límite alrededor.
|
||||||
- StatefulSet (por qué Postgres lo usa y el frontend no)
|
|
||||||
- Init container
|
**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
|
# Red y exposición
|
||||||
|
|
||||||
> **TODO (fase 2+):** explicar cada dominio, por qué está proxied vs
|
Los 5 dominios de abajo son reales y comparten exactamente el mismo
|
||||||
> DNS-only en Cloudflare, y el manejo de TLS extremo a extremo. Tabla
|
camino de red — ninguno tiene un caso especial.
|
||||||
> de dominios abajo es real, pendiente de expandir con capturas/ejemplos.
|
|
||||||
|
|
||||||
## Dominios activos
|
## Dominios activos
|
||||||
|
|
||||||
@@ -14,12 +13,63 @@
|
|||||||
| `gitea.cruzcloud.net` | Gitea | — | Cloudflare (edge) |
|
| `gitea.cruzcloud.net` | Gitea | — | Cloudflare (edge) |
|
||||||
| `docs.cruzcloud.net` | Este portal | `docs` | 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
|
Un registro DNS-only en Cloudflare expondría directamente la IP real
|
||||||
Cloudflare, no en Traefik — el tráfico interno NPM→k3d es HTTP plano).
|
del túnel — pero acá no hay IP pública fija que exponer: el NAS está
|
||||||
- Explicar por qué el transporte del túnel se fijó a HTTP/2 en vez de
|
detrás de NAT residencial. El modo proxied es lo que hace que
|
||||||
QUIC (ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)
|
Cloudflare Tunnel tenga sentido en primer lugar (el tráfico entra por
|
||||||
y el [playbook del 504](../playbooks/incidente-504-gitea-tunnel.md)).
|
el edge de Cloudflare, nunca directo al origen), así que no hay ningún
|
||||||
- Patrón de diagnóstico "interno sano, público roto" (ver
|
dominio de este lab para el que DNS-only sea una opción real.
|
||||||
[playbook de imágenes NPM](../playbooks/incidente-imagenes-npm.md)).
|
|
||||||
|
## 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
|
# Visión general
|
||||||
|
|
||||||
> **TODO (fase 2+):** narrativa completa explicando cada capa del
|
Este lab corre sobre un NAS ZimaOS doméstico, sin IP pública ni
|
||||||
> diagrama, con foco en el "por qué" de cada frontera de confianza. El
|
presupuesto de nube — cada capa del diagrama de abajo existe por una
|
||||||
> diagrama de abajo es real (validado contra `docs/known-issues.md` y
|
razón concreta de ese contexto, no por moda arquitectónica. Esta
|
||||||
> los playbooks de incidentes de `apps-registry`), pero el texto que lo
|
página explica el "por qué" de cada frontera; el "cómo" vive en las
|
||||||
> acompaña es solo el esqueleto.
|
páginas enlazadas al final de cada sección.
|
||||||
|
|
||||||
## Flujo de una petición pública
|
## 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` |
|
| 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 |
|
| 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)
|
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)
|
# ADR 0001 — Por qué k3d (y no k3s directo, minikube, o un cluster cloud)
|
||||||
|
|
||||||
> **TODO (fase 2+):** completar contexto/opciones/consecuencias con
|
**Estado:** aceptada
|
||||||
> criterio real (recursos del NAS ZimaOS, necesidad de multi-nodo para
|
**Fecha:** 2026-08 (retroactiva — decisión tomada al arrancar el lab, documentada en Fase 2 del portal)
|
||||||
> reproducir el blackhole de MTU documentado en `known-issues.md`,
|
|
||||||
> costo cero vs. cluster gestionado).
|
|
||||||
|
|
||||||
**Estado:** aceptada (placeholder — pendiente de redactar)
|
|
||||||
**Fecha:** TODO
|
|
||||||
|
|
||||||
## Contexto
|
## 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
|
## Opciones consideradas
|
||||||
|
|
||||||
| Opción | A favor | En contra |
|
| Opción | A favor | En contra |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| k3d | | |
|
| **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) | | |
|
| 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 | | |
|
| 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) | | |
|
| 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
|
## 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
|
## Consecuencias
|
||||||
|
|
||||||
TODO — mencionar explícitamente el blackhole de red cross-node
|
- **Se ganó** un cluster real y gratuito, reproducible con un solo
|
||||||
(MTU/PMTUD) como consecuencia real y documentada de correr multi-nodo
|
script (`scripts/deploy-lab.sh`), y suficientemente parecido a un
|
||||||
sobre Docker-in-Docker sin ajuste de MTU (ver
|
Kubernetes de producción como para que los incidentes documentados
|
||||||
[playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md)).
|
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)
|
# 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
|
**Estado:** aceptada
|
||||||
> reconciliación continua (`selfHeal`) sobre despliegue imperativo desde
|
**Fecha:** 2026-08 (retroactiva — decisión tomada al arrancar el lab, documentada en Fase 2 del portal)
|
||||||
> CI.
|
|
||||||
|
|
||||||
**Estado:** aceptada (placeholder — pendiente de redactar)
|
|
||||||
**Fecha:** TODO
|
|
||||||
|
|
||||||
## Contexto
|
## 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
|
## Opciones consideradas
|
||||||
|
|
||||||
| Opción | A favor | En contra |
|
| Opción | A favor | En contra |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Argo CD (GitOps, pull-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 | | |
|
| `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) | | |
|
| `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
|
## 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
|
## Consecuencias
|
||||||
|
|
||||||
TODO — mencionar el patrón App of Apps (`application.yaml` +
|
- **`selfHeal: true` cambia el modelo mental de quién puede tocar el
|
||||||
`apps/*.yaml`) y cómo `selfHeal:true` cambia el modelo mental de
|
cluster de verdad.** No es solo que Argo CD detecte diferencias y
|
||||||
"quién puede tocar el cluster".
|
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
|
intercalados en la grilla (PR #41, con fix-forward de tipos en
|
||||||
PR #42).
|
PR #42).
|
||||||
|
|
||||||
**Pendiente:** borrar el usuario admin temporal `[email protected]`
|
El usuario admin temporal `[email protected]` (creado para la
|
||||||
de Medusa Admin (creado para la auditoría de la sección 5, ya no
|
auditoría de la sección 5) fue borrado el 2026-08-27 vía Medusa Admin.
|
||||||
necesario). Sin próxima etapa definida todavía — este ADR no
|
Sin próxima etapa definida todavía — este ADR no especifica una
|
||||||
especifica una Etapa 3.
|
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
|
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,
|
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).
|
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.
|
||||||
@@ -93,6 +93,7 @@ nav:
|
|||||||
- "0004 · Stack multi-categoría": decisiones/0004-evaluacion-stack-multi-categoria.md
|
- "0004 · Stack multi-categoría": decisiones/0004-evaluacion-stack-multi-categoria.md
|
||||||
- "0005 · Rediseño storefront": decisiones/0005-rediseno-storefront.md
|
- "0005 · Rediseño storefront": decisiones/0005-rediseno-storefront.md
|
||||||
- "0006 · Motor de búsqueda y facetas": decisiones/0006-motor-busqueda-facetas.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
|
- Plantilla ADR: decisiones/plantilla-adr.md
|
||||||
- Playbooks:
|
- Playbooks:
|
||||||
- Crashloop Medusa: playbooks/incidente-crashloop-medusa.md
|
- Crashloop Medusa: playbooks/incidente-crashloop-medusa.md
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ spec:
|
|||||||
runAsNonRoot: false
|
runAsNonRoot: false
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- 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
|
# El base saca TODAS las capabilities. Probado en vivo y
|
||||||
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
|
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
|
||||||
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
|
# 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
|
memory: 64Mi
|
||||||
|
|
||||||
- name: migrations
|
- name: migrations
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.166
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- node
|
- node
|
||||||
@@ -86,7 +86,7 @@ spec:
|
|||||||
|
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.166
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { PromoTile } from "@/components/home/category-tiles";
|
|||||||
import { ProductCard } from "@/components/product/product-card";
|
import { ProductCard } from "@/components/product/product-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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
|
// Cada cuantas tarjetas de producto se intercala un tile promocional en
|
||||||
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
|
// 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
|
// Conteos por valor de filtro (patron Amazon: "Marca (12)"), calculados
|
||||||
// sobre los productos que ya pasan el resto de filtros activos -- cada
|
// sobre los productos que ya pasan el resto de filtros activos -- cada
|
||||||
// dimension se cuenta excluyendose a si misma para reflejar cuantos
|
// dimension se cuenta excluyendose a si misma para reflejar cuantos
|
||||||
// resultados habria si se eligiera ese valor.
|
// resultados habria si se eligiera ese valor. Sirven de fallback cuando
|
||||||
const categoryCounts = useMemo(() => {
|
// 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(
|
const base = products.filter(
|
||||||
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice),
|
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice),
|
||||||
);
|
);
|
||||||
@@ -170,7 +172,7 @@ export function CatalogClient({
|
|||||||
return counts;
|
return counts;
|
||||||
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
|
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
|
||||||
|
|
||||||
const brandCounts = useMemo(() => {
|
const fallbackBrandCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
(product) =>
|
(product) =>
|
||||||
matchesQuery(product, query) &&
|
matchesQuery(product, query) &&
|
||||||
@@ -187,7 +189,7 @@ export function CatalogClient({
|
|||||||
return counts;
|
return counts;
|
||||||
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
|
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
|
||||||
|
|
||||||
const ageCounts = useMemo(() => {
|
const fallbackAgeCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
(product) =>
|
(product) =>
|
||||||
matchesQuery(product, query) &&
|
matchesQuery(product, query) &&
|
||||||
@@ -204,6 +206,108 @@ export function CatalogClient({
|
|||||||
return counts;
|
return counts;
|
||||||
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]);
|
}, [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 = () => {
|
const reset = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setCategory("Todos");
|
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">
|
<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" />
|
<Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill priority sizes="80px" className="object-cover" />
|
||||||
</Link>
|
</Link>
|
||||||
<PredictiveSearch products={products} />
|
<PredictiveSearch />
|
||||||
<div className="flex items-center gap-2">
|
<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="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}>
|
<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 Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Search, X } from "lucide-react";
|
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 { Input } from "@/components/ui/input";
|
||||||
import type { Product } from "@/lib/headless/types";
|
import type { Product } from "@/lib/headless/types";
|
||||||
|
|
||||||
export function PredictiveSearch({ products }: { products: Product[] }) {
|
export function PredictiveSearch() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const normalized = query.trim().toLocaleLowerCase("es");
|
const [matches, setMatches] = useState<Product[]>([]);
|
||||||
const matches = useMemo(
|
const [loading, setLoading] = useState(false);
|
||||||
() => normalized.length < 2
|
const normalized = query.trim();
|
||||||
? []
|
|
||||||
: products.filter((product) => [product.name, product.category, product.collection, product.brand].join(" ").toLocaleLowerCase("es").includes(normalized)).slice(0, 6),
|
useEffect(() => {
|
||||||
[normalized, products],
|
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>) => {
|
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
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>}
|
{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>
|
||||||
</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 && (
|
{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">
|
<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) => (
|
{matches.map((product) => (
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ spec:
|
|||||||
runAsUser: 1001
|
runAsUser: 1001
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.157
|
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.176
|
||||||
env:
|
env:
|
||||||
- name: HEADLESS_PROVIDER
|
- name: HEADLESS_PROVIDER
|
||||||
value: medusa
|
value: medusa
|
||||||
|
|||||||
@@ -26,3 +26,7 @@ export async function searchProducts(query: string, limit?: number) {
|
|||||||
export async function getCategories() {
|
export async function getCategories() {
|
||||||
return provider().getCategories();
|
return provider().getCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function searchWithFacets(filters?: ProductFilters) {
|
||||||
|
return provider().searchWithFacets?.(filters);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
CatalogProvider,
|
CatalogProvider,
|
||||||
Category,
|
Category,
|
||||||
|
FacetDistribution,
|
||||||
|
FacetedSearchResult,
|
||||||
Product,
|
Product,
|
||||||
ProductFilters,
|
ProductFilters,
|
||||||
ProductVariant,
|
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 = {
|
export const medusaCatalogProvider: CatalogProvider = {
|
||||||
async getProducts(filters) {
|
async getProducts(filters) {
|
||||||
return fetchProducts(filters);
|
return fetchProducts(filters);
|
||||||
@@ -416,6 +468,11 @@ export const medusaCatalogProvider: CatalogProvider = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async searchProducts(query, limit = 8) {
|
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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -38,6 +38,10 @@ export interface ProductFilters {
|
|||||||
ageRange?: string;
|
ageRange?: string;
|
||||||
minPrice?: number;
|
minPrice?: number;
|
||||||
maxPrice?: 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 {
|
export interface Category {
|
||||||
@@ -47,9 +51,23 @@ export interface Category {
|
|||||||
parentId: string | null;
|
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 {
|
export interface CatalogProvider {
|
||||||
getProducts(filters?: ProductFilters): Promise<Product[]>;
|
getProducts(filters?: ProductFilters): Promise<Product[]>;
|
||||||
getProductBySlug(slug: string): Promise<Product | null>;
|
getProductBySlug(slug: string): Promise<Product | null>;
|
||||||
searchProducts(query: string, limit?: number): Promise<Product[]>;
|
searchProducts(query: string, limit?: number): Promise<Product[]>;
|
||||||
getCategories(): Promise<Category[]>;
|
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
|
|
||||||
Reference in new issue
Block a user