Compare commits

...
Author SHA1 Message Date
devops 5ce7030c4d docs(adr): propuesta de motor de busqueda y facetas (Fase 5 Etapa 3)
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 32s
Build and Push Docs Portal / Construir y publicar Docs Portal (pull_request) Skipped
Etapa 1 (auditoria/propuesta, sin codigo) de la Etapa 3 de Fase 5:
motor de busqueda y filtros facetados reales, reemplazando el
filtrado/conteo 100% client-side que dejo la sub-fase 3 de Etapa 2.

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

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

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

Detectado por el build real en main tras el merge de #41 -- no se
puede correr tsc localmente en esta maquina (ver notas de sesiones
anteriores), asi que esto solo lo valida el CI post-merge.
2026-08-25 15:18:12 -05:00
devops e7699befc5 Merge pull request 'fix(ecommerce): tiles promocionales intercalados en el grid del catalogo' (#41) from fix/etapa2-subfase3-tiles-promocionales into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 5m10s
2026-08-25 20:10:32 +00:00
devops 44d6e10ea2 chore: release v1.0.154 [skip ci] 2026-08-25 20:09:39 +00:00
devops f5de074ea1 Merge pull request 'fix(ecommerce): breadcrumb real + conteos por filtro en el catalogo' (#40) from fix/etapa2-subfase3-facetas-breadcrumb into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 26s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 13m4s
2026-08-25 19:56:04 +00:00
devops 8e74d0b516 chore: release v1.0.153 [skip ci] 2026-08-25 19:55:06 +00:00
devops e95ac086f0 Merge pull request 'fix(ecommerce): mensaje de urgencia por inventario real' (#39) from fix/etapa2-subfase3-urgencia-inventario into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m4s
2026-08-25 19:43:26 +00:00
devops 6a78257d71 chore: release v1.0.149 [skip ci] 2026-08-25 19:42:14 +00:00
devops 8fc9df3b50 feat(ecommerce): mensaje de urgencia por inventario real
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 26s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Expone ProductVariant.inventoryQuantity (ya calculado desde
variant.inventory_quantity en Medusa, hoy colapsado al booleano
available) y lo usa para mostrar "Quedan N" cuando el stock real es
bajo (<=5), tanto en la tarjeta de producto como en la ficha. null
indica inventario no gestionado (backorder / manage_inventory=false),
donde nunca se muestra un numero. ADR 0005 seccion 3 (patron eBay).

Catalogo mock actualizado (data/products.json) con
inventoryQuantity=null en todas las variantes para seguir tipando
contra Product[] via `satisfies`.
2026-08-25 14:33:14 -05:00
13 changed files with 374 additions and 41 deletions

No files matched your search

@@ -109,7 +109,10 @@ Un motor de búsqueda dedicado (Meilisearch, autohospedado) queda
identificado como el siguiente paso natural una vez el catálogo identificado como el siguiente paso natural una vez el catálogo
crezca más allá de ~100 productos activos, pero no es parte del crezca más allá de ~100 productos activos, pero no es parte del
alcance inmediato de Etapa 2 — se revisará cuando ese techo esté alcance inmediato de Etapa 2 — se revisará cuando ese techo esté
cerca. cerca. **Actualización 2026-08-25:** terminada la Etapa 2 (ver
[ADR 0005](0005-rediseno-storefront.md)), el disparador real resultó
ser la complejidad de facetas de la taxonomía, no el volumen de
productos — ver [ADR 0006](0006-motor-busqueda-facetas.md).
**Compatibilidad con Medusa Admin confirmada:** ninguna de las tres **Compatibilidad con Medusa Admin confirmada:** ninguna de las tres
opciones evaluadas, incluida la elegida, toca opciones evaluadas, incluida la elegida, toca
@@ -1,13 +1,14 @@
# ADR 0005 — Rediseño del storefront para catálogo multi-categoría # ADR 0005 — Rediseño del storefront para catálogo multi-categoría
**Estado:** propuesta — pendiente de aprobación antes de Etapa 2 **Estado:** aceptada — Etapa 2 implementada y en producción
**Fecha:** 2026-08-23 **Fecha:** 2026-08-23 (Etapa 1) — actualizado 2026-08-25 (Etapa 2 completa)
> Este documento es la propuesta de Etapa 1 del rediseño de ARI > Este documento arrancó como la propuesta de Etapa 1 del rediseño de
> Shopping. No incluye código de producción — solo auditoría, > ARI Shopping (auditoría, decisión — ver también [ADR 0004](0004-evaluacion-stack-multi-categoria.md) —
> decisión (ver también [ADR 0004](0004-evaluacion-stack-multi-categoria.md)), > patrones de UX y datos demo). Etapa 2 fue aprobada y se implementó
> patrones de UX y datos demo ya creados y visibles en producción > completa en tres sub-fases (datos/categorías, navegación,
> como línea base. > componentes visuales) — ver la sección 6 para el detalle de cada
> sub-fase y sus PRs.
## 1. Resumen de la auditoría ## 1. Resumen de la auditoría
@@ -255,12 +256,33 @@ objetivo es validar la tubería Admin → MinIO → NPM → frontend, no el
contenido visual final. contenido visual final.
Usuario admin temporal `[email protected]` usado para esta Usuario admin temporal `[email protected]` usado para esta
auditoría — pendiente de borrar una vez aprobada esta propuesta. auditoría — pendiente de borrar (ver sección 6).
## 6. Próximos pasos ## 6. Etapa 2 — implementada (2026-08-23 a 2026-08-25)
Pendiente de tu aprobación explícita antes de iniciar Etapa 2. Cuando Aprobada y ejecutada en tres sub-fases, cada una validada en vivo
apruebes, Etapa 2 se dividirá en sub-fases (datos/categorías primero, contra estos 9 productos demo:
luego navegación, luego componentes visuales), validando en cada paso
contra estos 9 productos demo que precio/descripción/imágenes siguen 1. **Datos/categorías** (2026-08-23/24): fix del bug de Store API que
actualizándose correctamente desde Admin. impedía ver la categoría real de un producto (causa raíz del ADR
0004); 21 categorías/9 productos demo creados; `legacyProductMeta`
eliminado (PR #29) — 17 productos legacy con categoría/colección/
marca/rango de edad reales.
2. **Navegación** (2026-08-25): navbar y tiles del home pasaron de
arrays hardcodeados a data-driven contra el árbol real de
categorías (`getCategories()`, PR #32); guard de CI actualizado
(PR #33); fix de tipos (PR #34); home/layout forzados a render
dinámico para evitar que quedaran cacheados con datos mock (PR #35).
3. **Componentes visuales** (2026-08-25), los 4 patrones de la
sección 3 más el breadcrumb/facetas de Amazon: mega-menú
"Departamentos" multi-columna (PR #36), badges antes/ahora
(PR #37), swatches de variante inline (PR #38), mensaje de
urgencia por inventario real (PR #39), breadcrumb real + conteos
por filtro en el catálogo (PR #40), tiles promocionales
intercalados en la grilla (PR #41, con fix-forward de tipos en
PR #42).
**Pendiente:** borrar el usuario admin temporal `[email protected]`
de Medusa Admin (creado para la auditoría de la sección 5, ya no
necesario). Sin próxima etapa definida todavía — este ADR no
especifica una Etapa 3.
@@ -0,0 +1,256 @@
# ADR 0006 — Motor de búsqueda y filtros facetados (Fase 5, Etapa 3)
**Estado:** propuesta — pendiente de aprobación antes de Etapa 2 (implementación)
**Fecha:** 2026-08-25
## Contexto
[ADR 0004](0004-evaluacion-stack-multi-categoria.md) ya identificó un
motor de búsqueda dedicado (Meilisearch, autohospedado) como "el
siguiente paso natural" para ARI Shopping, pero lo dejó fuera del
alcance de Etapa 2 explícitamente hasta que el catálogo se acercara al
techo real de ~100 productos activos que impone
`fetchProducts(filters, limit = 100)` en `medusa.ts`.
**Ese techo todavía no llegó** — el catálogo real hoy son 26 productos
(17 legacy + 9 demo, ver [ADR 0005](0005-rediseno-storefront.md)
sección 5), lejos de 100. El disparador de esta propuesta no es el
techo de volumen sino la **complejidad de facetas**: la Etapa 2 ya
construyó una taxonomía real de 6 categorías raíz / 21 nodos
(navegación, mega-menú, breadcrumb — PRs #36-42) y un filtrado con
conteo por valor (`categoryCounts`/`brandCounts`/`ageCounts` en
`catalog-client.tsx`, PR #40) que hoy se calcula **100% client-side**
sobre los productos ya traídos — el mismo patrón de "deuda de
alcance" que el hallazgo #4 de ADR 0004 ya señaló, ahora con más
dimensiones de filtro que multiplicar. Sigue siendo tratable a 26
productos; deja de serlo mucho antes de llegar a 100 si el catálogo
crece a la vez en número de productos y en dimensiones filtrables.
Restricción no negociable, igual que en toda la Fase 5: el flujo
**Admin → evento `product.updated` → `revalidate-storefront.ts` →
`revalidateTag("medusa-products")`**, que hoy mantiene el storefront
sincronizado sin intervención manual, no se toca. Cualquier índice de
búsqueda debe sincronizarse por el mismo mecanismo de eventos, nunca
por un cron ni un paso manual.
## 1. Estado real de la integración Medusa v2 + Meilisearch
Verificado contra la documentación oficial vigente el 2026-08-25, no
contra memoria de entrenamiento:
- **Medusa no tiene un módulo de búsqueda propio integrado con
Meilisearch hoy.** La guía oficial
(`docs.medusajs.com/resources/integrations/guides/meilisearch`) es
un tutorial de módulo custom desde cero (`npm install meilisearch` +
workflows/subscribers propios) — no un paquete instalable.
- **Medusa sí introdujo un "Search Module" propio en la versión
2.19.0.** El paquete comunitario `@rokmohar/medusa-plugin-meilisearch`
(el más usado y mantenido para Meilisearch+Medusa) se conecta a ese
Search Module desde su versión `^2.0.0`, pero esa versión del plugin
**requiere Medusa `^2.19.0`**.
- **Nuestro backend corre Medusa `2.18.0`** (`commerce-backend/package.json`)
— justo por debajo de ese corte. La tabla de compatibilidad del
plugin (`github.com/rokmohar/medusa-plugin-meilisearch`) resuelve
esto: la línea `^1.4.1` soporta `Medusa >= 2.15 < 2.19`, servidor
Meilisearch `>= 1.5`, Node `>= 22`. El propio repo lo explica: *"The
2.19 release removed the search interface the v1 line was written
against, so the two lines do not overlap: v1 stops at Medusa 2.18,
v2 starts at 2.19."*
- Node de `commerce-backend` ya es `22.18.0` (Dockerfile) — cumple el
mínimo del plugin sin cambios.
**Implicación concreta:** para adoptar Meilisearch sin subir de
versión Medusa (fuera de alcance de este ADR — sería su propia
migración), la pieza a instalar es `@rokmohar/medusa-plugin-meilisearch@^1.4.1`,
no la última versión del plugin. Esto es deuda técnica documentada,
no un obstáculo: si en el futuro se sube Medusa a `2.19+`, migrar a la
línea `^2.0.0` del plugin (y al Search Module nativo) es el camino
recto.
## 2. Impacto de recursos
Estado real del host ZimaOS al momento de esta auditoría (`free -h`,
`docker stats`): 15 GiB RAM totales, **swap al 100% (5.2/5.2 GiB
usados)**, solo 351 MiB realmente libres (6.5 GiB "available" vía
cache reclamable), 4 vCPU — la misma clase de contención que ya forzó
el fix de `cpu_shares` de Gitea. Cualquier servicio nuevo en este host
es una decisión de capacidad real, no teórica.
- **Meilisearch no publica un mínimo de RAM oficial.** Su propio FAQ
(`meilisearch.com/docs/resources/help/faq`) solo dice que "más RAM
implica búsquedas más rápidas" y recomienda ~10x el tamaño del
dataset crudo en disco — sin piso duro. La cifra de "~1 GiB
cómodo" que circula en blogs de la comunidad es una estimación, no
una garantía del proveedor — se cita aquí como referencia, no como
hecho verificado.
- Meilisearch usa almacenamiento memory-mapped: el índice puede vivir
parcialmente en disco cuando no entra en RAM, degradando latencia en
vez de fallar. A la escala actual (26 productos, dataset de KB) el
uso real de RAM del índice en sí es insignificante — el costo es el
proceso base de Meilisearch, no el dataset.
- **Comparación con Typesense** (pedida explícitamente, mantenida
breve porque la diferencia relevante para este caso es clara y no
requiere más profundidad): Typesense mantiene el índice completo en
RAM (~30% más RAM que Meilisearch para el mismo dataset, según
benchmarks públicos) y, a diferencia de Meilisearch, **no tiene
ningún plugin ni integración con Medusa** — habría que escribir la
sincronización completa desde cero igual que con la guía "custom"
de Meilisearch, pero sin ninguna base comunitaria mantenida
compatible con nuestra versión de Medusa. Con Meilisearch ya
teniendo un plugin activo para `Medusa 2.18` y menor huella de RAM,
no hay motivo para evaluar Typesense más a fondo en este caso.
- **Recomendación:** desplegar Meilisearch en k3d con `requests`/`limits`
explícitos y conservadores desde el día uno (ej. `request: 256Mi/0.1
CPU`, `limit: 768Mi/0.5 CPU` — a ajustar con datos reales de uso, no
supuestos), y tratarlo como el mismo tipo de riesgo operativo que ya
se vivió con Gitea: monitorear consumo real post-despliegue antes de
darlo por estable. Dado el swap ya saturado, este es el punto de
mayor incertidumbre de toda la propuesta — más que la elección de
motor de búsqueda en sí.
## 3. Modelo de indexación
Basado en la taxonomía real ya viva en Medusa (Etapa 2) y en
`Product`/`Category` de `lib/headless/types.ts`:
| Campo | Buscable (texto, tolerante a typos) | Filtrable / faceta | Ordenable |
|---|---|---|---|
| `name` | Sí | No | No |
| `shortDescription` / `description` | Sí | No | No |
| `category` (nombre de la hoja real) | Sí | Sí (con conteo) | No |
| `collection` | Sí | Sí (con conteo) | No |
| `brand` | Sí | Sí (con conteo) | No |
| `ageRange` | No | Sí (con conteo) | No |
| `tags` | Sí | No | No |
| `price` | No | Sí (rango) | Sí |
| `compareAtPrice` | No | No | No (solo se usa para el badge, no como filtro) |
| `isNew` / `featured` | No | No | Sí (orden "Novedades"/"Destacados", igual que hoy) |
| `available` | No | Sí (booleano) | No |
| `slug`, `thumbnail`, `id` | No | No | No (solo se devuelven, no se indexan como facetas) |
La jerarquía de categorías (raíz/hija) no se indexa como un campo
aparte: el índice guarda el nombre de la hoja real tal como hoy lo
hace `product.category`, y el breadcrumb/mega-menú siguen resolviendo
la jerarquía contra `/store/product-categories` (sin cambios, ya
funciona desde la sub-fase 2). El motor de búsqueda resuelve
**búsqueda de texto + conteo de facetas**, no reemplaza la fuente de
la jerarquía.
## 4. Diseño de sincronización
Mismo patrón que `revalidate-storefront.ts`, no uno nuevo: un
subscriber adicional en `commerce-backend/src/subscribers/` (ej.
`sync-search-index.ts`) que reacciona a eventos y le pide al plugin
que reindexe el documento afectado — nunca Medusa escribiendo
directamente en Meilisearch fuera de este canal.
Eventos a escuchar, confirmados en la documentación oficial de
eventos de Medusa (`docs.medusajs.com/resources/events-reference`):
- `product.created` / `product.updated` / `product.deleted`
- `product-variant.created` / `product-variant.updated` / `product-variant.deleted`
(mismo set que ya usa `revalidate-storefront.ts`, hoy validado en
producción)
- **Nuevo respecto al subscriber actual:** `product-category.created`
/ `product-category.updated` / `product-category.deleted` — un
cambio de nombre o jerarquía de categoría debe refrescar las
facetas del índice, y hoy `revalidate-storefront.ts` no escucha
estos eventos porque el storefront los resuelve en cada request
contra `/store/product-categories` directamente, sin necesidad de
revalidación. El índice de búsqueda sí necesita enterarse.
**Punto a verificar en vivo en Etapa 2, no asumido aquí:** existe al
menos un reporte público de un evento `product-collection.updated`
que no se disparaba de forma confiable en ciertas versiones de Medusa
(`github.com/medusajs/medusa/issues/11197`). Antes de dar por cerrado
el diseño de sincronización hay que repetir exactamente la validación
que ya se hizo para `revalidate-storefront.ts` en la auditoría de
Etapa 1 de Fase 5: crear/editar una categoría real vía Admin y
confirmar en vivo que el evento llega al subscriber, no confiar en que
la documentación coincide con el comportamiento real de `2.18.0`
(el mismo tipo de brecha que ya se encontró una vez con la relación
`+categories` de `/store/products`).
## 5. Diseño de UI — filtros facetados y búsqueda
**Filtros facetados (sidebar de `/catalog`):** mismo layout que ya
construyó la sub-fase 3 (`catalog-client.tsx`, PR #40) — sidebar con
"Categoría"/"Marca"/"Edad" como pills con conteo entre paréntesis y
slider de precio máximo. Lo único que cambia es **de dónde sale el
conteo**: hoy `categoryCounts`/`brandCounts`/`ageCounts` se calculan
recorriendo el arreglo de productos ya traído en el cliente; con el
motor de búsqueda, esos conteos vienen del propio Meilisearch
(`facetDistribution`) en la misma respuesta que trae los productos
filtrados, sin recorrer nada en el navegador. El breadcrumb no cambia
— sigue resolviéndose contra la jerarquía real de categorías, no
contra el índice de búsqueda.
```
Inicio > Mochilas y morrales > Marvel breadcrumb real (sin cambios)
┌────────────┬──────────────────────────────────────┐
│ Categoría │ [buscador con autocompletado] │ ← nuevo
│ ☐ Marvel(6)│ [card][card][card] │
│ ☐ Frozen(4)│ [card][card][card] │
│ Marca │ │
│ ☐ Disney(2)│ conteos ahora vienen de Meilisearch, │
│ Precio │ no de recorrer el arreglo en cliente │
│ [====○===] │ │
└────────────┴──────────────────────────────────────┘
```
**Búsqueda con autocompletado (barra del navbar):** hoy
`PredictiveSearch` (`components/search/predictive-search.tsx`) filtra
por substring sobre los productos ya cargados en memoria del cliente —
funciona a 26 productos, no escala y no tolera errores de tipeo (ADR
0004, hallazgo #5). Se reemplaza la fuente de datos del mismo
componente visual por `searchProducts(query)` del `CatalogProvider`
(interfaz que **ya existe** en `lib/headless/types.ts`, sin necesidad
de un tipo nuevo) apuntando a Meilisearch en vez de al `?q=` de
Medusa: tolerancia a typos y ranking por relevancia nativos del motor,
mismo contrato de datos que ya consume el dropdown de resultados —
cero cambio de componente React, solo de qué hay detrás de
`CatalogProvider.searchProducts`.
## Opciones consideradas
| Opción | A favor | En contra |
|---|---|---|
| **Meilisearch autohospedado** (`@rokmohar/medusa-plugin-meilisearch@^1.4.1`, sincronizado por eventos) | Plugin comunitario activo y compatible con nuestra versión exacta de Medusa (2.18); menor huella de RAM que la alternativa evaluada; almacenamiento memory-mapped se degrada en vez de fallar bajo presión de memoria; mismo patrón de subscriber que `revalidate-storefront.ts`, cero riesgo para ese flujo | Ningún paquete "oficial" de Medusa (el plugin es comunitario, no del core); atado a la línea `v1` del plugin mientras no se suba Medusa a 2.19+; un servicio más para operar en un host ya con swap saturado |
| **Typesense autohospedado** | Autocompletado ~2x más rápido según benchmarks públicos | Cero integración con Medusa (habría que escribir la sincronización completa desde cero, igual esfuerzo que la guía "custom" de Meilisearch pero sin base comunitaria); ~30% más RAM que Meilisearch para el mismo dataset, el peor trade-off posible dado el estado actual del host |
| **Módulo custom desde cero** (seguir la guía oficial de Medusa, sin el plugin de rokmohar) | Sin dependencia de un paquete de terceros; control total | Reimplementa lo que el plugin comunitario ya resuelve (declaración de índices, reindexado atómico, tracking de escrituras); más superficie propia para mantener sin ganancia clara sobre usar el plugin ya compatible con 2.18 |
| **No adoptar motor de búsqueda todavía** (seguir con filtrado 100% client-side) | Cero riesgo, cero componente nuevo | No resuelve la deuda de facetas ya identificada en ADR 0004 y agravada por la sub-fase 3; el techo de complejidad de filtros llega antes que el de 100 productos si el catálogo sigue creciendo en categorías |
## Decisión
**Meilisearch autohospedado**, vía `@rokmohar/medusa-plugin-meilisearch@^1.4.1`
(no la última versión del plugin — esa exige Medusa 2.19+, fuera de
alcance), sincronizado por el mismo patrón de subscriber por eventos
que ya usa `revalidate-storefront.ts`, con `product-category.*` sumado
al set de eventos existente. Se despliega en k3d con límites de
recursos conservadores desde el primer día y se trata como
experimento a monitorear, no como capacidad garantizada, dado el
estado actual de memoria del host.
**No se sube la versión de Medusa como parte de esta decisión** —
queda como deuda técnica documentada para cuando se justifique
migrar a la línea `v2` del plugin y al Search Module nativo de
Medusa.
## Consecuencias
- Se gana: conteo de facetas real (no client-side), búsqueda tolerante
a errores de tipeo con ranking por relevancia, y una arquitectura
que ya no depende del techo de 100 productos de `fetchProducts`.
- Se acepta como deuda técnica: quedar atado a la línea `v1` del
plugin de Meilisearch mientras Medusa siga en `2.18.x`; migrar a
Medusa `2.19+` es un proyecto aparte, no parte de esta Etapa 3.
- Riesgo operativo real y no resuelto todavía por este ADR: el host ya
corre con el swap saturado — el despliegue de Etapa 2 debe empezar
con límites de recursos conservadores y validación en vivo de
consumo real antes de confiar en el servicio, siguiendo la misma
lección que dejó el incidente de recursos de Gitea.
- Revertir esta decisión implica desinstalar el plugin y el
subscriber nuevo, y volver el buscador/filtrado a 100% client-side
— no hay migración de datos que deshacer, porque Meilisearch nunca
es la fuente de verdad, solo una copia de solo lectura indexada
desde Medusa.
+1
View File
@@ -91,6 +91,7 @@ nav:
- "0003 · HTTP/2 sobre QUIC": decisiones/0003-por-que-http2-sobre-quic-tunnel.md - "0003 · HTTP/2 sobre QUIC": decisiones/0003-por-que-http2-sobre-quic-tunnel.md
- "0004 · Stack multi-categoría": decisiones/0004-evaluacion-stack-multi-categoria.md - "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
- 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.125 image: gitea.cruzcloud.net/devops/docs-portal:v1.0.159
# 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
@@ -144,8 +144,9 @@ export function CatalogClient({
filtered.forEach((product, index) => { filtered.forEach((product, index) => {
items.push({ type: "product", product }); items.push({ type: "product", product });
const isLast = index === filtered.length - 1; const isLast = index === filtered.length - 1;
if (!isLast && (index + 1) % PROMO_TILE_INTERVAL === 0) { const promoCategory = promoRoots[promoIndex % promoRoots.length];
items.push({ type: "promo", category: promoRoots[promoIndex % promoRoots.length] }); if (!isLast && (index + 1) % PROMO_TILE_INTERVAL === 0 && promoCategory) {
items.push({ type: "promo", category: promoCategory });
promoIndex += 1; promoIndex += 1;
} }
}); });
@@ -2,12 +2,12 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Heart, ShoppingBag, Sparkles } from "lucide-react"; import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types"; import type { Product } from "@/lib/headless/types";
import { formatMoney } from "@/lib/utils"; import { formatMoney, LOW_STOCK_THRESHOLD } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store"; import { useCartStore } from "@/store/cart-store";
import { useWishlistStore } from "@/store/wishlist-store"; import { useWishlistStore } from "@/store/wishlist-store";
@@ -23,6 +23,14 @@ export function ProductCard({ product, priority = false }: { product: Product; p
: null; : null;
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id); const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id);
const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0];
const lowStock =
selectedVariant?.inventoryQuantity !== null &&
selectedVariant?.inventoryQuantity !== undefined &&
selectedVariant.inventoryQuantity > 0 &&
selectedVariant.inventoryQuantity <= LOW_STOCK_THRESHOLD
? selectedVariant.inventoryQuantity
: null;
return ( return (
<article className="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-lg shadow-slate-900/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-violet-500/10"> <article className="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-lg shadow-slate-900/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-violet-500/10">
@@ -56,6 +64,9 @@ export function ProductCard({ product, priority = false }: { product: Product; p
))} ))}
</div> </div>
)} )}
{lowStock !== null && (
<span className="flex items-center gap-1.5 text-xs font-bold text-amber-600"><TriangleAlert className="size-3.5" />Quedan {lowStock}</span>
)}
<div className="mt-auto flex items-center justify-between gap-3 pt-1"> <div className="mt-auto flex items-center justify-between gap-3 pt-1">
<div className="flex flex-col"> <div className="flex flex-col">
{product.compareAtPrice && <span className="text-xs font-bold text-slate-400 line-through">{formatMoney(product.compareAtPrice, product.currency)}</span>} {product.compareAtPrice && <span className="text-xs font-bold text-slate-400 line-through">{formatMoney(product.compareAtPrice, product.currency)}</span>}
@@ -1,14 +1,22 @@
"use client"; "use client";
import { CheckCircle2, ShoppingBag, Truck } from "lucide-react"; import { CheckCircle2, ShoppingBag, TriangleAlert, Truck } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types"; import type { Product } from "@/lib/headless/types";
import { formatMoney } from "@/lib/utils"; import { formatMoney, LOW_STOCK_THRESHOLD } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store"; import { useCartStore } from "@/store/cart-store";
export function ProductPurchase({ product }: { product: Product }) { export function ProductPurchase({ product }: { product: Product }) {
const [variantId, setVariantId] = useState(product.variants[0]?.id); const [variantId, setVariantId] = useState(product.variants[0]?.id);
const addItem = useCartStore((state) => state.addItem); const addItem = useCartStore((state) => state.addItem);
return <div><div className="mb-5 flex flex-wrap gap-2">{product.variants.map((variant) => <button key={variant.id} onClick={() => setVariantId(variant.id)} className={`rounded-full border px-4 py-2 text-sm font-bold ${variantId === variant.id ? "border-fuchsia-500 bg-fuchsia-50 text-fuchsia-700" : "border-slate-200"}`}>{variant.value}</button>)}</div><div className="mb-6 flex items-end gap-3"><strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}</div><Button variant="magenta" size="lg" className="w-full" onClick={() => addItem(product, variantId)}><ShoppingBag />Añadir al carrito</Button><div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm"><span className="flex items-center gap-2 font-bold"><CheckCircle2 className="size-4 text-emerald-500" />Disponibilidad por confirmar</span><span className="flex items-center gap-2 font-bold"><Truck className="size-4 text-cyan-600" />Envíos nacionales</span></div></div>; const selectedVariant = product.variants.find((variant) => variant.id === variantId) ?? product.variants[0];
const lowStock =
selectedVariant?.inventoryQuantity !== null &&
selectedVariant?.inventoryQuantity !== undefined &&
selectedVariant.inventoryQuantity > 0 &&
selectedVariant.inventoryQuantity <= LOW_STOCK_THRESHOLD
? selectedVariant.inventoryQuantity
: null;
return <div><div className="mb-5 flex flex-wrap gap-2">{product.variants.map((variant) => <button key={variant.id} onClick={() => setVariantId(variant.id)} className={`rounded-full border px-4 py-2 text-sm font-bold ${variantId === variant.id ? "border-fuchsia-500 bg-fuchsia-50 text-fuchsia-700" : "border-slate-200"}`}>{variant.value}</button>)}</div><div className="mb-6 flex items-end gap-3"><strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}</div><Button variant="magenta" size="lg" className="w-full" onClick={() => addItem(product, variantId)}><ShoppingBag />Añadir al carrito</Button><div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm">{lowStock !== null ? <span className="flex items-center gap-2 font-bold text-amber-600"><TriangleAlert className="size-4" />Quedan {lowStock} unidades</span> : <span className="flex items-center gap-2 font-bold"><CheckCircle2 className="size-4 text-emerald-500" />Disponibilidad por confirmar</span>}<span className="flex items-center gap-2 font-bold"><Truck className="size-4 text-cyan-600" />Envíos nacionales</span></div></div>;
} }
+34 -17
View File
@@ -30,7 +30,8 @@
"id": "ari-001-toy-story-5-rex-default", "id": "ari-001-toy-story-5-rex-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-001-toy-story-5-rex.webp" "thumbnail": "/images/thumbnails/ari-001-toy-story-5-rex.webp"
@@ -66,7 +67,8 @@
"id": "ari-002-toy-story-5-buzz-lightyear-default", "id": "ari-002-toy-story-5-buzz-lightyear-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-002-toy-story-5-buzz-lightyear.webp" "thumbnail": "/images/thumbnails/ari-002-toy-story-5-buzz-lightyear.webp"
@@ -102,7 +104,8 @@
"id": "ari-003-toy-story-5-jessie-default", "id": "ari-003-toy-story-5-jessie-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-003-toy-story-5-jessie.webp" "thumbnail": "/images/thumbnails/ari-003-toy-story-5-jessie.webp"
@@ -138,7 +141,8 @@
"id": "ari-004-toy-story-5-woody-default", "id": "ari-004-toy-story-5-woody-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-004-toy-story-5-woody.webp" "thumbnail": "/images/thumbnails/ari-004-toy-story-5-woody.webp"
@@ -174,7 +178,8 @@
"id": "ari-005-intensamente-furia-default", "id": "ari-005-intensamente-furia-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-005-intensamente-furia.webp" "thumbnail": "/images/thumbnails/ari-005-intensamente-furia.webp"
@@ -210,7 +215,8 @@
"id": "ari-006-intensamente-tristeza-default", "id": "ari-006-intensamente-tristeza-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-006-intensamente-tristeza.webp" "thumbnail": "/images/thumbnails/ari-006-intensamente-tristeza.webp"
@@ -246,7 +252,8 @@
"id": "ari-007-minnie-y-mickey-mouse-default", "id": "ari-007-minnie-y-mickey-mouse-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-007-minnie-y-mickey-mouse.webp" "thumbnail": "/images/thumbnails/ari-007-minnie-y-mickey-mouse.webp"
@@ -282,7 +289,8 @@
"id": "ari-008-angel-lilo-y-stitch-default", "id": "ari-008-angel-lilo-y-stitch-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-008-angel-lilo-y-stitch.webp" "thumbnail": "/images/thumbnails/ari-008-angel-lilo-y-stitch.webp"
@@ -318,7 +326,8 @@
"id": "ari-009-mochilas-disney-coleccion-marvel-default", "id": "ari-009-mochilas-disney-coleccion-marvel-default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-009-mochilas-disney-coleccion-marvel.webp" "thumbnail": "/images/thumbnails/ari-009-mochilas-disney-coleccion-marvel.webp"
@@ -354,7 +363,8 @@
"id": "ari-010-mochila-ligera-y-resistente-al-agua-de-d-default", "id": "ari-010-mochila-ligera-y-resistente-al-agua-de-d-default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp" "thumbnail": "/images/thumbnails/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp"
@@ -390,7 +400,8 @@
"id": "ari-011-mochila-escolar-de-spider-man-de-disney--default", "id": "ari-011-mochila-escolar-de-spider-man-de-disney--default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-011-mochila-escolar-de-spider-man-de-disney-.webp" "thumbnail": "/images/thumbnails/ari-011-mochila-escolar-de-spider-man-de-disney-.webp"
@@ -426,7 +437,8 @@
"id": "ari-012-disney-mochila-de-poliester-ultraligera--default", "id": "ari-012-disney-mochila-de-poliester-ultraligera--default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-012-disney-mochila-de-poliester-ultraligera-.webp" "thumbnail": "/images/thumbnails/ari-012-disney-mochila-de-poliester-ultraligera-.webp"
@@ -462,7 +474,8 @@
"id": "ari-013-mochila-pequena-bob-esponja-default", "id": "ari-013-mochila-pequena-bob-esponja-default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-013-mochila-pequena-bob-esponja.webp" "thumbnail": "/images/thumbnails/ari-013-mochila-pequena-bob-esponja.webp"
@@ -498,7 +511,8 @@
"id": "ari-014-mochila-casual-de-gran-capacidad-con-est-default", "id": "ari-014-mochila-casual-de-gran-capacidad-con-est-default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-014-mochila-casual-de-gran-capacidad-con-est.webp" "thumbnail": "/images/thumbnails/ari-014-mochila-casual-de-gran-capacidad-con-est.webp"
@@ -534,7 +548,8 @@
"id": "ari-015-mochila-escolar-estilo-britanico-de-disn-default", "id": "ari-015-mochila-escolar-estilo-britanico-de-disn-default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-015-mochila-escolar-estilo-britanico-de-disn.webp" "thumbnail": "/images/thumbnails/ari-015-mochila-escolar-estilo-britanico-de-disn.webp"
@@ -570,7 +585,8 @@
"id": "ari-016-disney-mochila-iron-man-de-poliester-ult-default", "id": "ari-016-disney-mochila-iron-man-de-poliester-ult-default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-016-disney-mochila-iron-man-de-poliester-ult.webp" "thumbnail": "/images/thumbnails/ari-016-disney-mochila-iron-man-de-poliester-ult.webp"
@@ -606,7 +622,8 @@
"id": "ari-017-mochila-de-disney-marvel-capitan-america-default", "id": "ari-017-mochila-de-disney-marvel-capitan-america-default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-017-mochila-de-disney-marvel-capitan-america.webp" "thumbnail": "/images/thumbnails/ari-017-mochila-de-disney-marvel-capitan-america.webp"
@@ -25,7 +25,7 @@ spec:
runAsUser: 1001 runAsUser: 1001
containers: containers:
- name: app - name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.147 image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.157
env: env:
- name: HEADLESS_PROVIDER - name: HEADLESS_PROVIDER
value: medusa value: medusa
@@ -157,11 +157,19 @@ function mapVariant(variant: MedusaVariant): ProductVariant {
variant.manage_inventory === false || variant.manage_inventory === false ||
(variant.inventory_quantity ?? 0) > 0; (variant.inventory_quantity ?? 0) > 0;
// null = inventario no gestionado (backorder o manage_inventory=false),
// no hay un numero real que mostrar como "quedan N".
const inventoryQuantity =
variant.allow_backorder === true || variant.manage_inventory === false
? null
: (variant.inventory_quantity ?? 0);
return { return {
id: variant.id, id: variant.id,
name: option?.option?.title || "Presentación", name: option?.option?.title || "Presentación",
value: option?.value || variant.title || "Estándar", value: option?.value || variant.title || "Estándar",
available, available,
inventoryQuantity,
}; };
} }
@@ -3,6 +3,7 @@ export interface ProductVariant {
name: string; name: string;
value: string; value: string;
available: boolean; available: boolean;
inventoryQuantity: number | null;
} }
export interface Product { export interface Product {
+5
View File
@@ -13,3 +13,8 @@ export function formatMoney(value: number | null, currency = "COP"): string {
maximumFractionDigits: 0, maximumFractionDigits: 0,
}).format(value); }).format(value);
} }
// Umbral desde el cual mostramos "Quedan N" en vez de solo "Disponible".
// inventoryQuantity=null significa inventario no gestionado (backorder /
// manage_inventory=false en Medusa) -- ahi nunca mostramos el mensaje.
export const LOW_STOCK_THRESHOLD = 5;