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 7a67ba0743 feat(ecommerce): tiles promocionales intercalados en el grid del catalogo
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 23s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
ADR 0005 seccion 3 (patron Walmart), ultimo item de la sub-fase de
componentes visuales: "grid de catalogo con tiles promocionales
intercalados, no solo tarjetas de producto homogeneas".

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

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

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

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

Catalogo mock actualizado (data/products.json) con
inventoryQuantity=null en todas las variantes para seguir tipando
contra Product[] via `satisfies`.
2026-08-25 14:33:14 -05:00
devops 707a05cc6e Merge pull request 'fix(ecommerce): selector de variante inline en tarjeta de producto' (#38) from fix/etapa2-subfase3-swatches-inline into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 25s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 10m37s
2026-08-25 19:31:15 +00:00
devops e20f3e1ddf feat(ecommerce): selector de variante inline en tarjeta de producto
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 39s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Reutiliza Product.variants[].{name,value,available}, ya expuesto por el
provider de Medusa (variant.options[0]), para swatches clicables en la
tarjeta del catalogo/home -- no solo en la ficha de producto. Solo se
muestra cuando el producto tiene mas de una variante; la variante
seleccionada se usa al agregar al carrito. ADR 0005 seccion 3 (patron
eBay), cero cambio de modelo de datos.
2026-08-25 14:29:50 -05:00
devops 5b03066ed2 chore: release v1.0.147 [skip ci] 2026-08-25 19:24:43 +00:00
devops 78cc5ef93b Merge pull request 'fix(ecommerce): badge antes/ahora en tarjetas de producto' (#37) from fix/etapa2-subfase3-badges-antes-ahora into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 11m41s
2026-08-25 19:12:37 +00:00
devops 9a8e12fa8b feat(ecommerce): badge antes/ahora en tarjetas de producto
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 22s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Reutiliza Product.compareAtPrice (ya calculado en el provider de Medusa
via original_amount > calculated_amount) para mostrar precio tachado +
badge de descuento en la grilla/catalogo, no solo en la ficha de
producto. ADR 0005 seccion 3, patron Walmart -- cero cambio de modelo
de datos.
2026-08-25 14:10:33 -05:00
devops e36525e0d8 chore: release v1.0.145 [skip ci] 2026-08-25 19:05:55 +00:00
devops b00dd14f76 Merge pull request 'fix(ecommerce): mega-menu de Departamentos multi-columna en el navbar' (#36) from fix/etapa2-subfase3-visuales into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 28s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 15m21s
2026-08-25 18:50:11 +00:00
devops 97f6fb91d8 feat(ecommerce): mega-menu de Departamentos multi-columna en el navbar
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 31s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Reemplaza la fila horizontal de pildoras por categoria (ADR 0005 seccion
3, patron Walmart) por un trigger unico "Departamentos" que abre un
panel multi-columna con las 6 categorias raiz y sus hijos, activado por
hover (mouse) o tap (touch), con cierre por click-fuera y Escape.
2026-08-25 13:44:38 -05:00
devops 40f8628e5d chore: release v1.0.143 [skip ci] 2026-08-25 05:12:02 +00:00
devops e7533b402d Merge pull request 'fix(ecommerce): forzar renderizado dinamico en home y layout raiz' (#35) from fix/etapa2-subfase2-home-force-dynamic into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 1m6s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 16m48s
2026-08-25 04:54:22 +00:00
16 changed files with 708 additions and 162 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
crezca más allá de ~100 productos activos, pero no es parte del
alcance inmediato de Etapa 2 — se revisará cuando ese techo esté
cerca.
cerca. **Actualización 2026-08-25:** terminada la Etapa 2 (ver
[ADR 0005](0005-rediseno-storefront.md)), el disparador real resultó
ser la complejidad de facetas de la taxonomía, no el volumen de
productos — ver [ADR 0006](0006-motor-busqueda-facetas.md).
**Compatibilidad con Medusa Admin confirmada:** ninguna de las tres
opciones evaluadas, incluida la elegida, toca
@@ -1,13 +1,14 @@
# ADR 0005 — Rediseño del storefront para catálogo multi-categoría
**Estado:** propuesta — pendiente de aprobación antes de Etapa 2
**Fecha:** 2026-08-23
**Estado:** aceptada — Etapa 2 implementada y en producción
**Fecha:** 2026-08-23 (Etapa 1) — actualizado 2026-08-25 (Etapa 2 completa)
> Este documento es la propuesta de Etapa 1 del rediseño de ARI
> Shopping. No incluye código de producción — solo auditoría,
> decisión (ver también [ADR 0004](0004-evaluacion-stack-multi-categoria.md)),
> patrones de UX y datos demo ya creados y visibles en producción
> como línea base.
> Este documento arrancó como la propuesta de Etapa 1 del rediseño de
> ARI Shopping (auditoría, decisión — ver también [ADR 0004](0004-evaluacion-stack-multi-categoria.md) —
> patrones de UX y datos demo). Etapa 2 fue aprobada y se implementó
> completa en tres sub-fases (datos/categorías, navegación,
> componentes visuales) — ver la sección 6 para el detalle de cada
> sub-fase y sus PRs.
## 1. Resumen de la auditoría
@@ -255,12 +256,33 @@ objetivo es validar la tubería Admin → MinIO → NPM → frontend, no el
contenido visual final.
Usuario admin temporal `[email protected]` usado para esta
auditoría — pendiente de borrar una vez aprobada esta propuesta.
auditoría — pendiente de borrar (ver sección 6).
## 6. Próximos pasos
## 6. Etapa 2 — implementada (2026-08-23 a 2026-08-25)
Pendiente de tu aprobación explícita antes de iniciar Etapa 2. Cuando
apruebes, Etapa 2 se dividirá en sub-fases (datos/categorías primero,
luego navegación, luego componentes visuales), validando en cada paso
contra estos 9 productos demo que precio/descripción/imágenes siguen
actualizándose correctamente desde Admin.
Aprobada y ejecutada en tres sub-fases, cada una validada en vivo
contra estos 9 productos demo:
1. **Datos/categorías** (2026-08-23/24): fix del bug de Store API que
impedía ver la categoría real de un producto (causa raíz del ADR
0004); 21 categorías/9 productos demo creados; `legacyProductMeta`
eliminado (PR #29) — 17 productos legacy con categoría/colección/
marca/rango de edad reales.
2. **Navegación** (2026-08-25): navbar y tiles del home pasaron de
arrays hardcodeados a data-driven contra el árbol real de
categorías (`getCategories()`, PR #32); guard de CI actualizado
(PR #33); fix de tipos (PR #34); home/layout forzados a render
dinámico para evitar que quedaran cacheados con datos mock (PR #35).
3. **Componentes visuales** (2026-08-25), los 4 patrones de la
sección 3 más el breadcrumb/facetas de Amazon: mega-menú
"Departamentos" multi-columna (PR #36), badges antes/ahora
(PR #37), swatches de variante inline (PR #38), mensaje de
urgencia por inventario real (PR #39), breadcrumb real + conteos
por filtro en el catálogo (PR #40), tiles promocionales
intercalados en la grilla (PR #41, con fix-forward de tipos en
PR #42).
**Pendiente:** borrar el usuario admin temporal `[email protected]`
de Medusa Admin (creado para la auditoría de la sección 5, ya no
necesario). Sin próxima etapa definida todavía — este ADR no
especifica una Etapa 3.
@@ -0,0 +1,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
- "0004 · Stack multi-categoría": decisiones/0004-evaluacion-stack-multi-categoria.md
- "0005 · Rediseño storefront": decisiones/0005-rediseno-storefront.md
- "0006 · Motor de búsqueda y facetas": decisiones/0006-motor-busqueda-facetas.md
- Plantilla ADR: decisiones/plantilla-adr.md
- Playbooks:
- Crashloop Medusa: playbooks/incidente-crashloop-medusa.md
@@ -25,7 +25,7 @@ spec:
runAsNonRoot: false
containers:
- name: app
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.125
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.159
# El base saca TODAS las capabilities. Probado en vivo y
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
+1
View File
@@ -41,6 +41,7 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
<CatalogClient
key={`${category}:${collection}:${query}`}
products={products}
categories={categories}
categoryGroups={categoryGroups}
initialCategory={category}
initialCollection={collection}
@@ -1,22 +1,70 @@
"use client";
import { SlidersHorizontal, X } from "lucide-react";
import Link from "next/link";
import { ChevronRight, SlidersHorizontal, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { PromoTile } from "@/components/home/category-tiles";
import { ProductCard } from "@/components/product/product-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { Product } from "@/lib/headless/types";
import type { Category, Product } from "@/lib/headless/types";
// Cada cuantas tarjetas de producto se intercala un tile promocional en
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
// promocionales intercalados, no solo tarjetas homogeneas.
const PROMO_TILE_INTERVAL = 8;
type GridItem = { type: "product"; product: Product } | { type: "promo"; category: Category };
type CatalogClientProps = {
products: Product[];
categories?: Category[];
categoryGroups?: Record<string, string[]>;
initialCategory?: string;
initialCollection?: string;
initialQuery?: string;
};
function matchesQuery(product: Product, query: string): boolean {
if (!query) return true;
const search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase();
return search.includes(query.toLowerCase());
}
function matchesCategory(product: Product, allowedCategories: string[]): boolean {
return allowedCategories.includes(product.category);
}
function matchesCollection(product: Product, collection: string): boolean {
return !collection || product.collection === collection;
}
function matchesBrand(product: Product, brand: string): boolean {
return brand === "Todas" || product.brand === brand;
}
function matchesAge(product: Product, age: string): boolean {
return age === "Todas" || product.ageRange === age;
}
function matchesPrice(product: Product, maxPrice: number): boolean {
return product.price === null || maxPrice === 0 || product.price <= maxPrice;
}
// Camina parentId hacia arriba desde la categoria seleccionada para armar
// el breadcrumb real (Inicio > raiz > hija), igual que en Amazon.
function buildBreadcrumb(categories: Category[], name: string): Category[] {
const node = categories.find((category) => category.name === name);
if (!node) return [];
const trail = [node];
let current = node;
while (current.parentId) {
const parent = categories.find((category) => category.id === current.parentId);
if (!parent) break;
trail.unshift(parent);
current = parent;
}
return trail;
}
export function CatalogClient({
products,
categories: categoryNodes = [],
categoryGroups = {},
initialCategory = "Todos",
initialCollection = "",
@@ -55,29 +103,25 @@ export function CatalogClient({
// tiene la raiz asignada directamente. Si no es una raiz conocida, se
// trata como el nombre de categoria exacto de siempre.
const allowedCategories = categoryGroups[category] ?? [category];
const breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]);
// No promociona la misma categoria raiz que el usuario ya esta viendo.
const promoRoots = useMemo(() => {
const excludeRootId = breadcrumb[0]?.id;
return categoryNodes.filter((node) => !node.parentId && node.id !== excludeRootId);
}, [categoryNodes, breadcrumb]);
const filtered = useMemo(
() =>
products
.filter((product) => {
const search = [
product.name,
product.category,
product.collection,
product.brand,
]
.join(" ")
.toLowerCase();
return (
(!query || search.includes(query.toLowerCase())) &&
(category === "Todos" || allowedCategories.includes(product.category)) &&
(!collection || product.collection === collection) &&
(brand === "Todas" || product.brand === brand) &&
(age === "Todas" || product.ageRange === age) &&
(product.price === null || maxPrice === 0 || product.price <= maxPrice)
);
})
.filter(
(product) =>
matchesQuery(product, query) &&
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) &&
matchesBrand(product, brand) &&
matchesAge(product, age) &&
matchesPrice(product, maxPrice),
)
.sort((a, b) =>
sort === "name"
? a.name.localeCompare(b.name, "es")
@@ -88,6 +132,78 @@ export function CatalogClient({
[age, allowedCategories, brand, category, collection, maxPrice, products, query, sort],
);
// Intercala un tile promocional cada PROMO_TILE_INTERVAL productos,
// ciclando por las categorias raiz -- si no hay jerarquia de
// categorias disponible, la grilla queda igual que antes (solo
// productos).
const gridItems = useMemo<GridItem[]>(() => {
if (promoRoots.length === 0) return filtered.map((product) => ({ type: "product", product }));
const items: GridItem[] = [];
let promoIndex = 0;
filtered.forEach((product, index) => {
items.push({ type: "product", product });
const isLast = index === filtered.length - 1;
const promoCategory = promoRoots[promoIndex % promoRoots.length];
if (!isLast && (index + 1) % PROMO_TILE_INTERVAL === 0 && promoCategory) {
items.push({ type: "promo", category: promoCategory });
promoIndex += 1;
}
});
return items;
}, [filtered, promoRoots]);
// Conteos por valor de filtro (patron Amazon: "Marca (12)"), calculados
// sobre los productos que ya pasan el resto de filtros activos -- cada
// dimension se cuenta excluyendose a si misma para reflejar cuantos
// resultados habria si se eligiera ese valor.
const categoryCounts = useMemo(() => {
const base = products.filter(
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice),
);
const counts: Record<string, number> = { Todos: base.length };
for (const name of categories) {
if (name === "Todos") continue;
const allowed = categoryGroups[name] ?? [name];
counts[name] = base.filter((product) => matchesCategory(product, allowed)).length;
}
return counts;
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
const brandCounts = useMemo(() => {
const base = products.filter(
(product) =>
matchesQuery(product, query) &&
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) &&
matchesAge(product, age) &&
matchesPrice(product, maxPrice),
);
const counts: Record<string, number> = { Todas: base.length };
for (const value of brands) {
if (value === "Todas") continue;
counts[value] = base.filter((product) => product.brand === value).length;
}
return counts;
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
const ageCounts = useMemo(() => {
const base = products.filter(
(product) =>
matchesQuery(product, query) &&
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) &&
matchesBrand(product, brand) &&
matchesPrice(product, maxPrice),
);
const counts: Record<string, number> = { Todas: base.length };
for (const value of ages) {
if (value === "Todas") continue;
counts[value] = base.filter((product) => product.ageRange === value).length;
}
return counts;
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]);
const reset = () => {
setQuery("");
setCategory("Todos");
@@ -101,6 +217,27 @@ export function CatalogClient({
return (
<div className="mx-auto max-w-7xl px-4 py-10">
<div className="mb-8">
{breadcrumb.length > 0 && (
<nav aria-label="Breadcrumb" className="mb-4 flex flex-wrap items-center gap-1.5 text-xs font-bold text-slate-500">
<Link href="/catalog" prefetch={false} className="hover:text-fuchsia-600">
Inicio
</Link>
{breadcrumb.map((node, index) => (
<span key={node.id} className="flex items-center gap-1.5">
<ChevronRight className="size-3" />
{index === breadcrumb.length - 1 ? (
<span aria-current="page" className="text-slate-900">
{node.name}
</span>
) : (
<Link href={`/catalog?category=${encodeURIComponent(node.name)}`} prefetch={false} className="hover:text-fuchsia-600">
{node.name}
</Link>
)}
</span>
))}
</nav>
)}
<span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">
Catálogo completo
</span>
@@ -134,9 +271,10 @@ export function CatalogClient({
values={categories}
selected={category}
onChange={setCategory}
counts={categoryCounts}
/>
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} />
<Filter title="Edad" values={ages} selected={age} onChange={setAge} />
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} />
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />
<fieldset>
<legend className="mb-3 text-sm font-black">Precio máximo</legend>
@@ -180,9 +318,13 @@ export function CatalogClient({
{filtered.length ? (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((product) => (
<ProductCard key={product.id} product={product} />
))}
{gridItems.map((item, index) =>
item.type === "product" ? (
<ProductCard key={item.product.id} product={item.product} />
) : (
<PromoTile key={`promo-${index}`} category={item.category} className="min-h-[22rem]" />
),
)}
</div>
) : (
<div className="grid min-h-80 place-items-center rounded-3xl border border-dashed border-slate-300 text-center">
@@ -205,11 +347,13 @@ function Filter({
values,
selected,
onChange,
counts,
}: {
title: string;
values: string[];
selected: string;
onChange: (value: string) => void;
counts?: Record<string, number>;
}) {
return (
<fieldset>
@@ -227,6 +371,7 @@ function Filter({
}`}
>
{value}
{counts && <span className="ml-1 opacity-60">({counts[value] ?? 0})</span>}
</button>
))}
</div>
@@ -2,6 +2,7 @@ import Image from "next/image";
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
import type { Category } from "@/lib/headless/types";
import { cn } from "@/lib/utils";
type TileContent = { subtitle: string; image?: string; color: string };
@@ -20,6 +21,37 @@ const tileContent: Record<string, TileContent> = {
const defaultTile: TileContent = { subtitle: "Explora esta categoría", color: "from-slate-400 to-slate-600" };
// Tile individual reutilizado tanto por la seccion de mundos del home
// como por el grid del catalogo (tiles promocionales intercalados,
// ADR 0005 seccion 3, patron Walmart) -- mismo primitivo visual, no un
// componente nuevo.
export function PromoTile({ category, className = "" }: { category: Category; className?: string }) {
const content = tileContent[category.handle] ?? defaultTile;
return (
<Link
href={`/catalog?category=${encodeURIComponent(category.name)}`}
className={cn(`group relative min-h-64 overflow-hidden rounded-[2rem] bg-gradient-to-br ${content.color} shadow-xl`, className)}
>
{content.image && (
<>
<Image src={content.image} alt={category.name} fill sizes="(max-width: 640px) 100vw, (max-width: 1024px) 33vw, 16vw" className="object-cover transition duration-500 group-hover:scale-105" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-white/5" />
</>
)}
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-6 text-white">
<div>
<h3 className="text-xl font-black">{category.name}</h3>
<p className="mt-1 text-sm font-semibold text-white/80">{content.subtitle}</p>
</div>
<span className="grid size-11 shrink-0 place-items-center rounded-full bg-white text-slate-950 transition group-hover:rotate-45">
<ArrowUpRight />
</span>
</div>
</Link>
);
}
export function CategoryTiles({ categories }: { categories: Category[] }) {
const roots = categories.filter((category) => !category.parentId);
@@ -35,33 +67,9 @@ export function CategoryTiles({ categories }: { categories: Category[] }) {
<Link href="/catalog" className="hidden font-black text-cyan-700 sm:block">Ver todo →</Link>
</div>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{roots.map((category) => {
const content = tileContent[category.handle] ?? defaultTile;
return (
<Link
key={category.id}
href={`/catalog?category=${encodeURIComponent(category.name)}`}
className={`group relative min-h-64 overflow-hidden rounded-[2rem] bg-gradient-to-br ${content.color} shadow-xl`}
>
{content.image && (
<>
<Image src={content.image} alt={category.name} fill sizes="(max-width: 640px) 100vw, (max-width: 1024px) 33vw, 16vw" className="object-cover transition duration-500 group-hover:scale-105" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-white/5" />
</>
)}
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-6 text-white">
<div>
<h3 className="text-xl font-black">{category.name}</h3>
<p className="mt-1 text-sm font-semibold text-white/80">{content.subtitle}</p>
</div>
<span className="grid size-11 shrink-0 place-items-center rounded-full bg-white text-slate-950 transition group-hover:rotate-45">
<ArrowUpRight />
</span>
</div>
</Link>
);
})}
{roots.map((category) => (
<PromoTile key={category.id} category={category} />
))}
</div>
</section>
);
@@ -3,8 +3,8 @@
import Image from "next/image";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { ChevronDown, Heart, Menu, MessageCircle, PackageCheck, ShoppingCart, UserRound } from "lucide-react";
import { Suspense, useMemo, useState } from "react";
import { ChevronDown, Heart, LayoutGrid, Menu, MessageCircle, PackageCheck, ShoppingCart, UserRound } from "lucide-react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { PredictiveSearch } from "@/components/search/predictive-search";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
@@ -151,94 +151,120 @@ function mobileLinkClass(active: boolean): string {
function DesktopCategoryNavigation({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
const { isActive, isAllActive } = useCategoryNavigationState();
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={isActive} isAllActive={isAllActive} />;
}
function DesktopCategoryNavigationFallback({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={() => false} isAllActive={false} />;
}
function CategoryNavBar({
tree,
onOpenMenu,
isActive,
isAllActive,
}: {
tree: CategoryNode[];
onOpenMenu: () => void;
isActive: (name: string) => boolean;
isAllActive: boolean;
}) {
return (
<nav className="border-b-4 border-fuchsia-500 bg-slate-950 text-white">
<div className="mx-auto flex max-w-7xl items-center gap-1 overflow-x-auto px-4 py-2 [scrollbar-width:none]">
<div className="mx-auto flex max-w-7xl items-center gap-1 px-4 py-2">
<Button
variant="ghost"
size="icon"
onClick={onOpenMenu}
aria-label="Abrir menú principal"
className="mr-3 shrink-0 text-white hover:bg-white/10"
className="mr-2 shrink-0 text-white hover:bg-white/10"
>
<Menu />
</Button>
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
<Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
Todo
</Link>
{tree.map((root) =>
root.children.length > 0 ? (
<div key={root.id} className="group relative shrink-0">
<Link
href={`/catalog?category=${encodeURIComponent(root.name)}`}
prefetch={false}
aria-current={isActive(root.name) ? "page" : undefined}
className={`flex items-center gap-1 ${pillClass(isActive(root.name))}`}
>
{root.name}
<ChevronDown className="size-3.5 opacity-70" />
</Link>
<div className="invisible absolute left-0 top-full z-50 min-w-56 -translate-y-1 rounded-2xl border border-slate-800 bg-slate-950 p-2 opacity-0 shadow-2xl transition group-hover:visible group-hover:translate-y-0 group-hover:opacity-100">
{root.children.map((child) => (
<Link
key={child.id}
href={`/catalog?category=${encodeURIComponent(child.name)}`}
prefetch={false}
aria-current={isActive(child.name) ? "page" : undefined}
className={`block rounded-xl px-3 py-2 text-sm font-bold transition ${
isActive(child.name) ? "bg-violet-600 text-white" : "text-white/85 hover:bg-white/10"
}`}
>
{child.name}
</Link>
))}
</div>
</div>
) : (
<Link
key={root.id}
href={`/catalog?category=${encodeURIComponent(root.name)}`}
prefetch={false}
aria-current={isActive(root.name) ? "page" : undefined}
className={pillClass(isActive(root.name))}
>
{root.name}
</Link>
),
)}
</div>
</nav>
);
}
function DesktopCategoryNavigationFallback({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<nav className="border-b-4 border-fuchsia-500 bg-slate-950 text-white">
<div className="mx-auto flex max-w-7xl items-center gap-1 overflow-x-auto px-4 py-2 [scrollbar-width:none]">
<Button
variant="ghost"
size="icon"
onClick={onOpenMenu}
aria-label="Abrir menú principal"
className="mr-3 shrink-0 text-white hover:bg-white/10"
>
<Menu />
</Button>
<div ref={containerRef} className="relative shrink-0" onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-haspopup="true"
className={`flex items-center gap-1.5 ${pillClass(open)}`}
>
<LayoutGrid className="size-4" />
Departamentos
<ChevronDown className={`size-3.5 opacity-70 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
<Link href="/catalog" prefetch={false} className={pillClass(false)}>
Todo
</Link>
{tree.map((root) => (
<Link key={root.id} href={`/catalog?category=${encodeURIComponent(root.name)}`} prefetch={false} className={pillClass(false)}>
{root.name}
</Link>
))}
</div>
</nav>
{open && (
<div className="absolute left-0 top-full z-50 mt-1 w-[min(92vw,64rem)] rounded-2xl border border-slate-800 bg-slate-950 p-6 shadow-2xl">
<div className="grid grid-cols-2 gap-x-6 gap-y-5 sm:grid-cols-3 lg:grid-cols-6">
{tree.map((root) => (
<div key={root.id}>
<Link
href={`/catalog?category=${encodeURIComponent(root.name)}`}
prefetch={false}
aria-current={isActive(root.name) ? "page" : undefined}
onClick={() => setOpen(false)}
className={`block text-sm font-black uppercase tracking-wide transition ${
isActive(root.name) ? "text-fuchsia-400" : "text-white hover:text-fuchsia-400"
}`}
>
{root.name}
</Link>
{root.children.length > 0 && (
<ul className="mt-2.5 grid gap-1.5">
{root.children.map((child) => (
<li key={child.id}>
<Link
href={`/catalog?category=${encodeURIComponent(child.name)}`}
prefetch={false}
aria-current={isActive(child.name) ? "page" : undefined}
onClick={() => setOpen(false)}
className={`block text-sm transition ${isActive(child.name) ? "font-bold text-violet-300" : "text-white/70 hover:text-white"}`}
>
{child.name}
</Link>
</li>
))}
</ul>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -2,11 +2,12 @@
import Image from "next/image";
import Link from "next/link";
import { Heart, ShoppingBag, Sparkles } from "lucide-react";
import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
import { formatMoney } from "@/lib/utils";
import { formatMoney, LOW_STOCK_THRESHOLD } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store";
import { useWishlistStore } from "@/store/wishlist-store";
@@ -16,19 +17,63 @@ export function ProductCard({ product, priority = false }: { product: Product; p
const wishlistHydrated = useWishlistStore((state) => state.hasHydrated);
const toggleProduct = useWishlistStore((state) => state.toggleProduct);
const isFavorite = wishlistHydrated && wishlistProducts.some((item) => item.id === product.id);
const discountPercent =
product.compareAtPrice && product.price
? Math.round((1 - product.price / product.compareAtPrice) * 100)
: null;
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id);
const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0];
const lowStock =
selectedVariant?.inventoryQuantity !== null &&
selectedVariant?.inventoryQuantity !== undefined &&
selectedVariant.inventoryQuantity > 0 &&
selectedVariant.inventoryQuantity <= LOW_STOCK_THRESHOLD
? selectedVariant.inventoryQuantity
: null;
return (
<article className="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-lg shadow-slate-900/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-violet-500/10">
<div className="relative aspect-square overflow-hidden bg-slate-50">
<Link href={`/product/${product.slug}`} prefetch={false}><Image src={product.thumbnail} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px" className="object-cover transition duration-500 group-hover:scale-[1.035]" /></Link>
<div className="absolute left-3 top-3 flex gap-2">{product.isNew && <Badge className="bg-fuchsia-500 text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-yellow-300 text-slate-950"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
<div className="absolute left-3 top-3 flex gap-2">{discountPercent && discountPercent > 0 && <Badge className="bg-rose-600 text-white">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-fuchsia-500 text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-yellow-300 text-slate-950"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
<button type="button" aria-label={isFavorite ? "Quitar de favoritos" : "Agregar a favoritos"} aria-pressed={isFavorite} className={`absolute right-3 top-3 rounded-full p-2.5 shadow-lg backdrop-blur transition ${isFavorite ? "bg-fuchsia-500 text-white" : "bg-white/95 text-slate-900 hover:text-fuchsia-500"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button>
</div>
<div className="flex flex-1 flex-col space-y-3 p-5">
<div className="flex items-center justify-between gap-2 text-xs font-bold text-slate-500"><span>{product.category}</span><span className="rounded-full bg-cyan-50 px-2 py-1 text-cyan-700">{product.collection}</span></div>
<Link href={`/product/${product.slug}`} prefetch={false}><h3 className="line-clamp-2 min-h-12 text-lg font-black leading-6 text-slate-950 group-hover:text-fuchsia-600">{product.name}</h3></Link>
<p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p>
<div className="mt-auto flex items-center justify-between gap-3 pt-1"><strong className="text-sm font-black">{formatMoney(product.price, product.currency)}</strong><Button variant="magenta" size="sm" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag className="size-4" />Agregar</Button></div>
{product.variants.length > 1 && (
<div className="flex flex-wrap gap-1.5">
{product.variants.map((variant) => (
<button
key={variant.id}
type="button"
disabled={!variant.available}
aria-pressed={selectedVariantId === variant.id}
aria-label={`${variant.name}: ${variant.value}`}
onClick={() => setSelectedVariantId(variant.id)}
className={`rounded-full border px-2.5 py-1 text-xs font-bold transition ${
selectedVariantId === variant.id
? "border-fuchsia-500 bg-fuchsia-50 text-fuchsia-700"
: "border-slate-200 text-slate-600 hover:border-slate-400"
} ${!variant.available ? "cursor-not-allowed opacity-40" : ""}`}
>
{variant.value}
</button>
))}
</div>
)}
{lowStock !== null && (
<span className="flex items-center gap-1.5 text-xs font-bold text-amber-600"><TriangleAlert className="size-3.5" />Quedan {lowStock}</span>
)}
<div className="mt-auto flex items-center justify-between gap-3 pt-1">
<div className="flex flex-col">
{product.compareAtPrice && <span className="text-xs font-bold text-slate-400 line-through">{formatMoney(product.compareAtPrice, product.currency)}</span>}
<strong className="text-sm font-black">{formatMoney(product.price, product.currency)}</strong>
</div>
<Button variant="magenta" size="sm" onClick={() => addItem(product, selectedVariantId)}><ShoppingBag className="size-4" />Agregar</Button>
</div>
</div>
</article>
);
@@ -1,14 +1,22 @@
"use client";
import { CheckCircle2, ShoppingBag, Truck } from "lucide-react";
import { CheckCircle2, ShoppingBag, TriangleAlert, Truck } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
import { formatMoney } from "@/lib/utils";
import { formatMoney, LOW_STOCK_THRESHOLD } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store";
export function ProductPurchase({ product }: { product: Product }) {
const [variantId, setVariantId] = useState(product.variants[0]?.id);
const addItem = useCartStore((state) => state.addItem);
return <div><div className="mb-5 flex flex-wrap gap-2">{product.variants.map((variant) => <button key={variant.id} onClick={() => setVariantId(variant.id)} className={`rounded-full border px-4 py-2 text-sm font-bold ${variantId === variant.id ? "border-fuchsia-500 bg-fuchsia-50 text-fuchsia-700" : "border-slate-200"}`}>{variant.value}</button>)}</div><div className="mb-6 flex items-end gap-3"><strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}</div><Button variant="magenta" size="lg" className="w-full" onClick={() => addItem(product, variantId)}><ShoppingBag />Añadir al carrito</Button><div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm"><span className="flex items-center gap-2 font-bold"><CheckCircle2 className="size-4 text-emerald-500" />Disponibilidad por confirmar</span><span className="flex items-center gap-2 font-bold"><Truck className="size-4 text-cyan-600" />Envíos nacionales</span></div></div>;
const selectedVariant = product.variants.find((variant) => variant.id === variantId) ?? product.variants[0];
const lowStock =
selectedVariant?.inventoryQuantity !== null &&
selectedVariant?.inventoryQuantity !== undefined &&
selectedVariant.inventoryQuantity > 0 &&
selectedVariant.inventoryQuantity <= LOW_STOCK_THRESHOLD
? selectedVariant.inventoryQuantity
: null;
return <div><div className="mb-5 flex flex-wrap gap-2">{product.variants.map((variant) => <button key={variant.id} onClick={() => setVariantId(variant.id)} className={`rounded-full border px-4 py-2 text-sm font-bold ${variantId === variant.id ? "border-fuchsia-500 bg-fuchsia-50 text-fuchsia-700" : "border-slate-200"}`}>{variant.value}</button>)}</div><div className="mb-6 flex items-end gap-3"><strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}</div><Button variant="magenta" size="lg" className="w-full" onClick={() => addItem(product, variantId)}><ShoppingBag />Añadir al carrito</Button><div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm">{lowStock !== null ? <span className="flex items-center gap-2 font-bold text-amber-600"><TriangleAlert className="size-4" />Quedan {lowStock} unidades</span> : <span className="flex items-center gap-2 font-bold"><CheckCircle2 className="size-4 text-emerald-500" />Disponibilidad por confirmar</span>}<span className="flex items-center gap-2 font-bold"><Truck className="size-4 text-cyan-600" />Envíos nacionales</span></div></div>;
}
+34 -17
View File
@@ -30,7 +30,8 @@
"id": "ari-001-toy-story-5-rex-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-001-toy-story-5-rex.webp"
@@ -66,7 +67,8 @@
"id": "ari-002-toy-story-5-buzz-lightyear-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-002-toy-story-5-buzz-lightyear.webp"
@@ -102,7 +104,8 @@
"id": "ari-003-toy-story-5-jessie-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-003-toy-story-5-jessie.webp"
@@ -138,7 +141,8 @@
"id": "ari-004-toy-story-5-woody-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-004-toy-story-5-woody.webp"
@@ -174,7 +178,8 @@
"id": "ari-005-intensamente-furia-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-005-intensamente-furia.webp"
@@ -210,7 +215,8 @@
"id": "ari-006-intensamente-tristeza-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-006-intensamente-tristeza.webp"
@@ -246,7 +252,8 @@
"id": "ari-007-minnie-y-mickey-mouse-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-007-minnie-y-mickey-mouse.webp"
@@ -282,7 +289,8 @@
"id": "ari-008-angel-lilo-y-stitch-default",
"name": "Presentación",
"value": "Edición original",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-008-angel-lilo-y-stitch.webp"
@@ -318,7 +326,8 @@
"id": "ari-009-mochilas-disney-coleccion-marvel-default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-009-mochilas-disney-coleccion-marvel.webp"
@@ -354,7 +363,8 @@
"id": "ari-010-mochila-ligera-y-resistente-al-agua-de-d-default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp"
@@ -390,7 +400,8 @@
"id": "ari-011-mochila-escolar-de-spider-man-de-disney--default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-011-mochila-escolar-de-spider-man-de-disney-.webp"
@@ -426,7 +437,8 @@
"id": "ari-012-disney-mochila-de-poliester-ultraligera--default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-012-disney-mochila-de-poliester-ultraligera-.webp"
@@ -462,7 +474,8 @@
"id": "ari-013-mochila-pequena-bob-esponja-default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-013-mochila-pequena-bob-esponja.webp"
@@ -498,7 +511,8 @@
"id": "ari-014-mochila-casual-de-gran-capacidad-con-est-default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-014-mochila-casual-de-gran-capacidad-con-est.webp"
@@ -534,7 +548,8 @@
"id": "ari-015-mochila-escolar-estilo-britanico-de-disn-default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-015-mochila-escolar-estilo-britanico-de-disn.webp"
@@ -570,7 +585,8 @@
"id": "ari-016-disney-mochila-iron-man-de-poliester-ult-default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-016-disney-mochila-iron-man-de-poliester-ult.webp"
@@ -606,7 +622,8 @@
"id": "ari-017-mochila-de-disney-marvel-capitan-america-default",
"name": "Presentación",
"value": "Rosa",
"available": true
"available": true,
"inventoryQuantity": null
}
],
"thumbnail": "/images/thumbnails/ari-017-mochila-de-disney-marvel-capitan-america.webp"
@@ -25,7 +25,7 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.141
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.157
env:
- name: HEADLESS_PROVIDER
value: medusa
@@ -157,11 +157,19 @@ function mapVariant(variant: MedusaVariant): ProductVariant {
variant.manage_inventory === false ||
(variant.inventory_quantity ?? 0) > 0;
// null = inventario no gestionado (backorder o manage_inventory=false),
// no hay un numero real que mostrar como "quedan N".
const inventoryQuantity =
variant.allow_backorder === true || variant.manage_inventory === false
? null
: (variant.inventory_quantity ?? 0);
return {
id: variant.id,
name: option?.option?.title || "Presentación",
value: option?.value || variant.title || "Estándar",
available,
inventoryQuantity,
};
}
@@ -3,6 +3,7 @@ export interface ProductVariant {
name: string;
value: string;
available: boolean;
inventoryQuantity: number | null;
}
export interface Product {
+5
View File
@@ -13,3 +13,8 @@ export function formatMoney(value: number | null, currency = "COP"): string {
maximumFractionDigits: 0,
}).format(value);
}
// Umbral desde el cual mostramos "Quedan N" en vez de solo "Disponible".
// inventoryQuantity=null significa inventario no gestionado (backorder /
// manage_inventory=false en Medusa) -- ahi nunca mostramos el mensaje.
export const LOW_STOCK_THRESHOLD = 5;