Compare commits

..
Author SHA1 Message Date
devopsandClaude Sonnet 5 2abfec57ae fix(ci): actualizar guard de navegacion del catalogo tras PR #32
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 30s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
El guard de "Validar navegación real del catálogo" en build.yaml seguia
pinneado a la implementacion vieja de navbar.tsx: greppeaba literalmente
"collection=Marvel", "category=Mochilas" y "currentCollection", que eran
exactamente los valores hardcodeados que PR #32 elimino por estar rotos
(ya no correspondian a ningun dato real desde que se quito
legacyProductMeta en PR #29). El guard quedo validando el bug, no la
correccion, y bloqueo el build post-merge de PR #32.

Ahora valida el comportamiento real nuevo: que el nav arma su arbol
desde datos reales (buildCategoryTree), que linkea por nombre real de
categoria (encodeURIComponent(root.name)) y que expone el item activo
(aria-current={isActive(...)}). El check de remontaje en
app/catalog/page.tsx tambien se actualizo: la key ya no incluye
":${section}" porque ese query param nunca se usaba (dead code
eliminado en PR #32).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013dunTcReWMKhWgCPkvAVHD
2026-08-24 21:36:36 -05:00
devops f1f5f3612b Merge pull request 'fix(ecommerce): nav y tiles data-driven contra la jerarquia real de categorias' (#32) from fix/etapa2-subfase2-navegacion-datadriven into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 23s
2026-08-25 02:27:18 +00:00
devopsandClaude Sonnet 5 8742ce714c fix(ecommerce): nav y tiles data-driven contra la jerarquia real de categorias
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 35s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Sub-fase 2 de Etapa 2 (ADR 0005 seccion 6). Navbar y CategoryTiles usaban
arrays hardcodeados (Figuras/Peluches/Mochilas/Marvel) que ya no coinciden
con los nombres reales de categoria en Medusa desde que se elimino
legacyProductMeta (PR #29) -- todos esos links de navegacion quedaron
rotos silenciosamente (0 productos al filtrar).

- lib/headless: nuevo tipo Category + metodo getCategories() en
  CatalogProvider (medusa via /store/product-categories, mock deriva de
  su catalogo plano, shopify sin implementar como el resto de sus metodos).
- Navbar: arbol real de categorias (6 raices/21 nodos), dropdown de
  hijos por raiz en desktop, lista anidada en el sheet movil. Colecciones
  del sheet ahora se derivan de los productos reales en vez de un array
  hardcodeado con 2 nombres obsoletos (Marvel/Frozen vs. los reales
  Universo Marvel/Reino de Frozen).
- CategoryTiles: tiles de home por categoria raiz real; imagen solo donde
  ya existe asset, gradiente honesto donde no (no hay fotografia para las
  6 raices todavia, deuda explicita del ADR).
- catalog-client/catalog page: cuando el query param `category` es una
  raiz con hijos, se expande a los nombres reales de sus hijos para que
  el filtro (igualdad exacta de producto.category) siga funcionando --
  sin esto, tiles/nav apuntando a una raiz mostrarian 0 productos porque
  ningun producto tiene la raiz asignada directamente.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013dunTcReWMKhWgCPkvAVHD
2026-08-24 21:21:18 -05:00
devops 0508c6328e chore: release v1.0.135 [skip ci] 2026-08-25 01:56:27 +00:00
devops 7485457cf4 Merge pull request 'fix(ci,ecommerce): escanear el manifiesto renderizado y endurecer readOnlyRootFilesystem del frontend' (#31) from fix/frontend-trivy-iac-and-readonly-fs 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 19m38s
2026-08-25 01:36:36 +00:00
devops 2670727cfa fix(ci,ecommerce): escanear el manifiesto renderizado y endurecer readOnlyRootFilesystem del frontend
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 23s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
- Trivy IaC escaneaba solo el patch aislado de Kustomize
  (workloads/ecommerce/frontend/patches/deployment.yaml), no lo que
  realmente corre en el clúster. Eso generaba falsos positivos en todo
  lo heredado del base remoto de platform-templates (runAsNonRoot,
  seccompProfile) porque el patch nunca los repite. Se instala kubectl,
  se renderiza el kustomization completo y se escanea ese output.
- readOnlyRootFilesystem pasa de false a true en el contenedor del
  frontend: revisando next.config.ts (optimización de imágenes) y
  app/api/revalidate/route.ts (revalidateTag sin cache handler custom),
  la imagen sí escribe a ./.next/cache en runtime. Se agregan volúmenes
  emptyDir para .next/cache y /tmp.
2026-08-24 20:35:08 -05:00
devops 3147cafaff chore: release v1.0.133 [skip ci] 2026-08-25 01:23:38 +00:00
devops cb5a08e7cc Merge pull request 'fix(ci): timeout de Trivy insuficiente en el scan de imagen del frontend' (#30) from fix/frontend-trivy-scan-timeout 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 17m24s
2026-08-25 00:56:15 +00:00
13 changed files with 388 additions and 144 deletions

No files matched your search

+42 -12
View File
@@ -127,6 +127,7 @@ jobs:
env: env:
APP_DIR: workloads/ecommerce APP_DIR: workloads/ecommerce
MANIFEST_FILE: workloads/ecommerce/frontend/patches/deployment.yaml MANIFEST_FILE: workloads/ecommerce/frontend/patches/deployment.yaml
KUSTOMIZE_DIR: workloads/ecommerce/frontend
IMAGE_NAME: gitea.cruzcloud.net/devops/ecommerce-frontend IMAGE_NAME: gitea.cruzcloud.net/devops/ecommerce-frontend
steps: steps:
@@ -213,22 +214,25 @@ jobs:
exit 1 exit 1
} }
grep -q 'currentCollection' "${NAVBAR_FILE}" || { # Desde Etapa 2 sub-fase 2 (2026-08-25) el nav es data-driven
echo "ERROR: navbar.tsx no calcula currentCollection." # contra el arbol real de categorias de Medusa, no un array
# hardcodeado -- esta version anterior del check no detectaba
# que "category=Mochilas"/"collection=Marvel" eran strings
# hardcodeadas que ya no correspondian a ningun dato real (rotas
# en silencio desde que se elimino legacyProductMeta, PR #29).
# "currentCollection" tampoco existe mas: las colecciones ya no
# se usan para resaltar el item activo del menu de categorias.
grep -q 'buildCategoryTree' "${NAVBAR_FILE}" || {
echo "ERROR: navbar.tsx ya no arma el arbol de categorias desde datos reales."
exit 1 exit 1
} }
grep -q 'collection=Marvel' "${NAVBAR_FILE}" || { grep -q 'encodeURIComponent(root.name)' "${NAVBAR_FILE}" || {
echo "ERROR: falta collection=Marvel en navbar.tsx." echo "ERROR: navbar.tsx ya no linkea por el nombre real de categoria."
exit 1 exit 1
} }
grep -q 'category=Mochilas' "${NAVBAR_FILE}" || { grep -q 'aria-current={isActive(' "${NAVBAR_FILE}" || {
echo "ERROR: falta category=Mochilas en navbar.tsx."
exit 1
}
grep -q 'aria-current={active ? "page" : undefined}' "${NAVBAR_FILE}" || {
echo "ERROR: navbar.tsx no expone correctamente el menú activo." echo "ERROR: navbar.tsx no expone correctamente el menú activo."
exit 1 exit 1
} }
@@ -257,7 +261,7 @@ jobs:
echo "Validando remontaje por parámetros: ${PAGE_FILE}" echo "Validando remontaje por parámetros: ${PAGE_FILE}"
grep -q 'key={`${category}:${collection}:${query}:${section}`}' "${PAGE_FILE}" || { grep -q 'key={`${category}:${collection}:${query}`}' "${PAGE_FILE}" || {
echo "ERROR: app/catalog/page.tsx no remonta CatalogClient al cambiar la URL." echo "ERROR: app/catalog/page.tsx no remonta CatalogClient al cambiar la URL."
exit 1 exit 1
} }
@@ -276,6 +280,32 @@ jobs:
chmod +x /tmp/trivy chmod +x /tmp/trivy
/tmp/trivy version /tmp/trivy version
- name: Instalar kubectl
shell: bash
run: |
set -euo pipefail
KUBECTL_VERSION="v1.31.2"
curl -sSfL \
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \
-o /tmp/kubectl
chmod +x /tmp/kubectl
/tmp/kubectl version --client
# ${MANIFEST_FILE} es solo el patch de Kustomize del Deployment, no el
# manifiesto que de verdad corre en el clúster -- escanearlo directo
# generaba falsos positivos en todo lo que hereda del base remoto de
# platform-templates (runAsNonRoot, seccompProfile), porque ese patch
# nunca repite lo que ya trae el base (visto en vivo 2026-08-24:
# comparando el patch aislado contra el output real de
# "kubectl kustomize", 2 de 5 hallazgos desaparecían). Renderizamos el
# kustomization completo (${KUSTOMIZE_DIR}, que sí trae el base vía
# git remoto) y escaneamos eso.
- name: Renderizar manifiesto Kustomize
shell: bash
run: |
set -euo pipefail
/tmp/kubectl kustomize "${KUSTOMIZE_DIR}" > /tmp/frontend-rendered.yaml
# Escanea el manifiesto Kubernetes real del frontend (no la imagen): # Escanea el manifiesto Kubernetes real del frontend (no la imagen):
# contenedores como root, falta de resource limits, falta de # contenedores como root, falta de resource limits, falta de
# readiness/liveness probes, etc. Informativo por ahora — no bloquea # readiness/liveness probes, etc. Informativo por ahora — no bloquea
@@ -287,7 +317,7 @@ jobs:
/tmp/trivy config \ /tmp/trivy config \
--severity CRITICAL,HIGH,MEDIUM \ --severity CRITICAL,HIGH,MEDIUM \
--exit-code 0 \ --exit-code 0 \
"${MANIFEST_FILE}" /tmp/frontend-rendered.yaml
# Instalado directo con pip (no como container aparte): el runner de # Instalado directo con pip (no como container aparte): el runner de
# Gitea ejecuta cada job ya dentro de un contenedor propio que habla # Gitea ejecuta cada job ya dentro de un contenedor propio que habla
+18 -4
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { CatalogClient } from "@/components/catalog/catalog-client"; import { CatalogClient } from "@/components/catalog/catalog-client";
import { getProducts } from "@/lib/headless/client"; import { getCategories, getProducts } from "@/lib/headless/client";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Catálogo", title: "Catálogo",
@@ -17,17 +17,31 @@ function single(value: string | string[] | undefined): string {
export default async function CatalogPage({ searchParams }: CatalogPageProps) { export default async function CatalogPage({ searchParams }: CatalogPageProps) {
const params = await searchParams; const params = await searchParams;
const products = await getProducts(); const [products, categories] = await Promise.all([getProducts(), getCategories()]);
const category = single(params.category) || "Todos"; const category = single(params.category) || "Todos";
const collection = single(params.collection); const collection = single(params.collection);
const query = single(params.q); const query = single(params.q);
const section = single(params.section);
// Los links de navbar/tiles apuntan a categorias raiz (ej. "Mochilas y
// morrales"), pero ningun producto tiene esa categoria asignada
// directamente -- solo sus hijos ("Marvel", "Frozen", etc.). Este mapa
// expande cada raiz-con-hijos al nombre real de sus hijos para que el
// filtro siga funcionando por igualdad exacta de `product.category`.
const categoryGroups: Record<string, string[]> = {};
for (const node of categories) {
if (node.parentId) continue;
const children = categories.filter((child) => child.parentId === node.id);
if (children.length > 0) {
categoryGroups[node.name] = children.map((child) => child.name);
}
}
return ( return (
<CatalogClient <CatalogClient
key={`${category}:${collection}:${query}:${section}`} key={`${category}:${collection}:${query}`}
products={products} products={products}
categoryGroups={categoryGroups}
initialCategory={category} initialCategory={category}
initialCollection={collection} initialCollection={collection}
initialQuery={query} initialQuery={query}
+3 -3
View File
@@ -4,7 +4,7 @@ import { CartDrawer } from "@/components/cart/cart-drawer";
import { Footer } from "@/components/layout/footer"; import { Footer } from "@/components/layout/footer";
import { Navbar } from "@/components/layout/navbar"; import { Navbar } from "@/components/layout/navbar";
import { WishlistDrawer } from "@/components/wishlist/wishlist-drawer"; import { WishlistDrawer } from "@/components/wishlist/wishlist-drawer";
import { getProducts } from "@/lib/headless/client"; import { getCategories, getProducts } from "@/lib/headless/client";
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://shop.cruzcloud.net"), metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://shop.cruzcloud.net"),
@@ -16,6 +16,6 @@ export const metadata: Metadata = {
export const viewport: Viewport = { width: "device-width", initialScale: 1, themeColor: "#0f172a" }; export const viewport: Viewport = { width: "device-width", initialScale: 1, themeColor: "#0f172a" };
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const products = await getProducts(); const [products, categories] = await Promise.all([getProducts(), getCategories()]);
return <html lang="es"><body><Navbar products={products} /><main>{children}</main><Footer /><CartDrawer /><WishlistDrawer /></body></html>; return <html lang="es"><body><Navbar products={products} categories={categories} /><main>{children}</main><Footer /><CartDrawer /><WishlistDrawer /></body></html>;
} }
+3 -3
View File
@@ -5,10 +5,10 @@ import { HeroBento } from "@/components/home/hero-bento";
import { TrustBar } from "@/components/home/trust-bar"; import { TrustBar } from "@/components/home/trust-bar";
import { ProductCarousel } from "@/components/product/product-carousel"; import { ProductCarousel } from "@/components/product/product-carousel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getProducts } from "@/lib/headless/client"; import { getCategories, getProducts } from "@/lib/headless/client";
export default async function HomePage() { export default async function HomePage() {
const products = await getProducts(); const [products, categories] = await Promise.all([getProducts(), getCategories()]);
const treasures = products.filter((product) => product.isNew || product.featured).slice(0, 8); const treasures = products.filter((product) => product.isNew || product.featured).slice(0, 8);
return <><HeroBento /><TrustBar /><div className="content-auto"><CategoryTiles /></div><section className="content-auto mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="content-auto mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-gradient-to-r from-cyan-500 via-blue-600 to-violet-600 text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-yellow-300">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div><div className="min-h-64 bg-[url('/images/promo/promo-backpacks.webp')] bg-cover bg-center" /></div></section></>; return <><HeroBento /><TrustBar /><div className="content-auto"><CategoryTiles categories={categories} /></div><section className="content-auto mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="content-auto mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-gradient-to-r from-cyan-500 via-blue-600 to-violet-600 text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-yellow-300">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div><div className="min-h-64 bg-[url('/images/promo/promo-backpacks.webp')] bg-cover bg-center" /></div></section></>;
} }
@@ -9,6 +9,7 @@ import type { Product } from "@/lib/headless/types";
type CatalogClientProps = { type CatalogClientProps = {
products: Product[]; products: Product[];
categoryGroups?: Record<string, string[]>;
initialCategory?: string; initialCategory?: string;
initialCollection?: string; initialCollection?: string;
initialQuery?: string; initialQuery?: string;
@@ -16,6 +17,7 @@ type CatalogClientProps = {
export function CatalogClient({ export function CatalogClient({
products, products,
categoryGroups = {},
initialCategory = "Todos", initialCategory = "Todos",
initialCollection = "", initialCollection = "",
initialQuery = "", initialQuery = "",
@@ -48,6 +50,12 @@ export function CatalogClient({
setMaxPrice(maxCatalogPrice); setMaxPrice(maxCatalogPrice);
}, [initialCategory, initialCollection, initialQuery, maxCatalogPrice]); }, [initialCategory, initialCollection, initialQuery, maxCatalogPrice]);
// Si `category` es una raiz con hijos (ej. "Mochilas y morrales"), se
// expande a la lista de nombres reales de sus hijos -- ningun producto
// 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 filtered = useMemo( const filtered = useMemo(
() => () =>
products products
@@ -63,7 +71,7 @@ export function CatalogClient({
return ( return (
(!query || search.includes(query.toLowerCase())) && (!query || search.includes(query.toLowerCase())) &&
(category === "Todos" || product.category === category) && (category === "Todos" || allowedCategories.includes(product.category)) &&
(!collection || product.collection === collection) && (!collection || product.collection === collection) &&
(brand === "Todas" || product.brand === brand) && (brand === "Todas" || product.brand === brand) &&
(age === "Todas" || product.ageRange === age) && (age === "Todas" || product.ageRange === age) &&
@@ -77,7 +85,7 @@ export function CatalogClient({
? Number(b.isNew) - Number(a.isNew) ? Number(b.isNew) - Number(a.isNew)
: Number(b.featured) - Number(a.featured), : Number(b.featured) - Number(a.featured),
), ),
[age, brand, category, collection, maxPrice, products, query, sort], [age, allowedCategories, brand, category, collection, maxPrice, products, query, sort],
); );
const reset = () => { const reset = () => {
@@ -1,14 +1,66 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ArrowUpRight } from "lucide-react"; import { ArrowUpRight } from "lucide-react";
import type { Category } from "@/lib/headless/types";
const categories = [ // No hay fotografia real todavia para las 6 categorias raiz de la taxonomia
{ title: "Juguetes", subtitle: "Personajes que inspiran", image: "/images/categories/figuras.webp", href: "/catalog?category=Figuras", color: "from-yellow-300 to-orange-400" }, // (ver ADR 0005) -- solo se mapean imagen/subtitulo para las que ya tienen
{ title: "Peluches", subtitle: "Compañeros para regalar", image: "/images/categories/peluches.webp", href: "/catalog?category=Peluches", color: "from-pink-400 to-fuchsia-500" }, // un asset en public/images/categories/. Las demas caen al tile de solo
{ title: "Mochilas", subtitle: "Estilo para cada aventura", image: "/images/categories/mochilas.webp", href: "/catalog?category=Mochilas", color: "from-cyan-400 to-blue-500" }, // gradiente en vez de mostrar una imagen que no les corresponde.
{ title: "Marvel", subtitle: "Tu héroe favorito", image: "/images/categories/marvel.webp", href: "/catalog?collection=Marvel", color: "from-red-500 to-rose-700" }, const tileContent: Record<string, { subtitle: string; image?: string; color: string }> = {
]; juguetes: { subtitle: "Personajes que inspiran", image: "/images/categories/figuras.webp", color: "from-yellow-300 to-orange-400" },
"mochilas-y-morrales": { subtitle: "Estilo para cada aventura", image: "/images/categories/mochilas.webp", color: "from-cyan-400 to-blue-500" },
"bebes-y-primera-infancia": { subtitle: "Primeros descubrimientos", color: "from-pink-300 to-fuchsia-400" },
educativos: { subtitle: "Aprender jugando", color: "from-emerald-400 to-teal-600" },
"accion-y-aventura": { subtitle: "Para explorar sin límites", color: "from-red-500 to-rose-700" },
variedades: { subtitle: "Un poco de todo", color: "from-violet-400 to-purple-600" },
};
export function CategoryTiles() { const defaultTile = { subtitle: "Explora esta categoría", color: "from-slate-400 to-slate-600" };
return <section className="mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-4"><div><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Explora por mundos</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Encuentra tu próximo tesoro</h2></div><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-4">{categories.map((item) => <Link key={item.title} href={item.href} className={`group relative min-h-80 overflow-hidden rounded-[2rem] bg-gradient-to-br ${item.color} shadow-xl`}><Image src={item.image} alt={item.title} fill sizes="(max-width: 640px) 100vw, 25vw" 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-2xl font-black">{item.title}</h3><p className="mt-1 text-sm font-semibold text-white/80">{item.subtitle}</p></div><span className="grid size-11 place-items-center rounded-full bg-white text-slate-950 transition group-hover:rotate-45"><ArrowUpRight /></span></div></Link>)}</div></section>;
export function CategoryTiles({ categories }: { categories: Category[] }) {
const roots = categories.filter((category) => !category.parentId);
if (roots.length === 0) return null;
return (
<section className="mx-auto max-w-7xl px-4 py-14">
<div className="mb-8 flex items-end justify-between gap-4">
<div>
<span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Explora por mundos</span>
<h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Encuentra tu próximo tesoro</h2>
</div>
<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>
);
})}
</div>
</section>
);
} }
+159 -109
View File
@@ -3,42 +3,28 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import { Heart, Menu, MessageCircle, PackageCheck, ShoppingCart, UserRound } from "lucide-react"; import { ChevronDown, Heart, Menu, MessageCircle, PackageCheck, ShoppingCart, UserRound } from "lucide-react";
import { Suspense, useState } from "react"; import { Suspense, useMemo, useState } from "react";
import { PredictiveSearch } from "@/components/search/predictive-search"; import { PredictiveSearch } from "@/components/search/predictive-search";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import type { Product } from "@/lib/headless/types"; import type { Category, Product } from "@/lib/headless/types";
import { useCartStore } from "@/store/cart-store"; import { useCartStore } from "@/store/cart-store";
import { useWishlistStore } from "@/store/wishlist-store"; import { useWishlistStore } from "@/store/wishlist-store";
type CategoryLink = { type CategoryNode = Category & { children: Category[] };
label: string;
href: string;
category?: string;
collection?: string;
section?: string;
root?: boolean;
};
const categories: CategoryLink[] = [ function buildCategoryTree(categories: Category[]): CategoryNode[] {
{ label: "Juguetes para bebés", href: "/catalog?category=Figuras", category: "Figuras" }, return categories
{ .filter((category) => !category.parentId)
label: "Educativos", .map((root) => ({
href: "/catalog?category=Figuras&section=educativos", ...root,
category: "Figuras", children: categories.filter((category) => category.parentId === root.id),
section: "educativos", }));
}, }
{ label: "Muñecas y peluches", href: "/catalog?category=Peluches", category: "Peluches" },
{ label: "Acción y aventura", href: "/catalog?collection=Marvel", collection: "Marvel" },
{ label: "Mochilas", href: "/catalog?category=Mochilas", category: "Mochilas" },
{ label: "Variedades", href: "/catalog", root: true },
];
const collections = ["Toy Story", "Marvel", "Intensamente", "Lilo & Stitch", "Frozen"]; export function Navbar({ products, categories }: { products: Product[]; categories: Category[] }) {
export function Navbar({ products }: { products: Product[] }) {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [accountOpen, setAccountOpen] = useState(false); const [accountOpen, setAccountOpen] = useState(false);
const items = useCartStore((state) => state.items); const items = useCartStore((state) => state.items);
@@ -50,6 +36,14 @@ export function Navbar({ products }: { products: Product[] }) {
const count = cartHydrated ? items.reduce((sum, item) => sum + item.quantity, 0) : 0; const count = cartHydrated ? items.reduce((sum, item) => sum + item.quantity, 0) : 0;
const wishlistCount = wishlistHydrated ? wishlistProducts.length : 0; const wishlistCount = wishlistHydrated ? wishlistProducts.length : 0;
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
// Derivado de los productos reales (no de un endpoint de colecciones):
// solo aparecen colecciones que de verdad tienen productos hoy.
const collections = useMemo(
() => [...new Set(products.map((product) => product.collection).filter(Boolean))],
[products],
);
return ( return (
<header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl"> <header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
<div className="bg-gradient-to-r from-amber-300 via-yellow-200 to-lime-200 px-4 py-2 text-center text-xs font-black text-slate-950 sm:text-sm"> <div className="bg-gradient-to-r from-amber-300 via-yellow-200 to-lime-200 px-4 py-2 text-center text-xs font-black text-slate-950 sm:text-sm">
@@ -77,8 +71,8 @@ export function Navbar({ products }: { products: Product[] }) {
</div> </div>
</div> </div>
<Suspense fallback={<DesktopCategoryNavigationFallback onOpenMenu={() => setMenuOpen(true)} />}> <Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} onOpenMenu={() => setMenuOpen(true)} />}>
<DesktopCategoryNavigation onOpenMenu={() => setMenuOpen(true)} /> <DesktopCategoryNavigation tree={tree} onOpenMenu={() => setMenuOpen(true)} />
</Suspense> </Suspense>
<Sheet open={menuOpen} onOpenChange={setMenuOpen}> <Sheet open={menuOpen} onOpenChange={setMenuOpen}>
@@ -90,14 +84,18 @@ export function Navbar({ products }: { products: Product[] }) {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<p className="mb-3 text-xs font-black uppercase tracking-[.16em] text-fuchsia-600">Categorías</p> <p className="mb-3 text-xs font-black uppercase tracking-[.16em] text-fuchsia-600">Categorías</p>
<div className="grid gap-2"> <div className="grid gap-2">
<Suspense fallback={<MobileCategoryLinksFallback onNavigate={() => setMenuOpen(false)} />}> <Suspense fallback={<MobileCategoryLinksFallback tree={tree} onNavigate={() => setMenuOpen(false)} />}>
<MobileCategoryLinks onNavigate={() => setMenuOpen(false)} /> <MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
</Suspense> </Suspense>
</div> </div>
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-violet-600">Colecciones</p> {collections.length > 0 && (
<div className="flex flex-wrap gap-2"> <>
{collections.map((collection) => <Link key={collection} href={`/catalog?collection=${encodeURIComponent(collection)}`} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-4 py-2 text-sm font-bold hover:bg-violet-100 hover:text-violet-700">{collection}</Link>)} <p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-violet-600">Colecciones</p>
</div> <div className="flex flex-wrap gap-2">
{collections.map((collection) => <Link key={collection} href={`/catalog?collection=${encodeURIComponent(collection)}`} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-4 py-2 text-sm font-bold hover:bg-violet-100 hover:text-violet-700">{collection}</Link>)}
</div>
</>
)}
<div className="mt-8 grid grid-cols-2 gap-3 sm:hidden"> <div className="mt-8 grid grid-cols-2 gap-3 sm:hidden">
<Button variant="outline" onClick={() => { setMenuOpen(false); setAccountOpen(true); }}><UserRound className="size-4" />Cuenta</Button> <Button variant="outline" onClick={() => { setMenuOpen(false); setAccountOpen(true); }}><UserRound className="size-4" />Cuenta</Button>
<Button variant="outline" onClick={() => { setMenuOpen(false); openWishlist(); }}><Heart className="size-4" />Favoritos</Button> <Button variant="outline" onClick={() => { setMenuOpen(false); openWishlist(); }}><Heart className="size-4" />Favoritos</Button>
@@ -127,29 +125,32 @@ function useCategoryNavigationState() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const currentCategory = searchParams.get("category") ?? ""; const currentCategory = searchParams.get("category") ?? "";
const currentCollection = searchParams.get("collection") ?? "";
const currentSection = searchParams.get("section") ?? "";
const isCatalog = pathname === "/catalog"; const isCatalog = pathname === "/catalog";
const isCategoryActive = (item: CategoryLink) => { return {
if (!isCatalog) return false; isAllActive: isCatalog && !currentCategory,
isActive: (name: string) => isCatalog && currentCategory === name,
if (item.root) {
return !currentCategory && !currentCollection && !currentSection;
}
return (
(item.category ?? "") === currentCategory &&
(item.collection ?? "") === currentCollection &&
(item.section ?? "") === currentSection
);
}; };
return { isCategoryActive };
} }
function DesktopCategoryNavigation({ onOpenMenu }: { onOpenMenu: () => void }) { function pillClass(active: boolean): string {
const { isCategoryActive } = useCategoryNavigationState(); return `shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${
active
? "bg-gradient-to-r from-cyan-500 to-violet-500 text-white shadow-lg shadow-violet-950/30"
: "text-white/90 hover:bg-gradient-to-r hover:from-cyan-500 hover:to-violet-500 hover:text-white"
}`;
}
function mobileLinkClass(active: boolean): string {
return `rounded-2xl border px-4 py-3 font-extrabold transition ${
active
? "border-violet-500 bg-violet-50 text-violet-700"
: "border-slate-200 hover:border-cyan-400 hover:bg-cyan-50"
}`;
}
function DesktopCategoryNavigation({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
const { isActive, isAllActive } = useCategoryNavigationState();
return ( return (
<nav className="border-b-4 border-fuchsia-500 bg-slate-950 text-white"> <nav className="border-b-4 border-fuchsia-500 bg-slate-950 text-white">
@@ -164,31 +165,56 @@ function DesktopCategoryNavigation({ onOpenMenu }: { onOpenMenu: () => void }) {
<Menu /> <Menu />
</Button> </Button>
{categories.map((category) => { <Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
const active = isCategoryActive(category); Todo
</Link>
return ( {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 <Link
key={category.label} key={root.id}
href={category.href} href={`/catalog?category=${encodeURIComponent(root.name)}`}
prefetch={false} prefetch={false}
aria-current={active ? "page" : undefined} aria-current={isActive(root.name) ? "page" : undefined}
className={`shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${ className={pillClass(isActive(root.name))}
active
? "bg-gradient-to-r from-cyan-500 to-violet-500 text-white shadow-lg shadow-violet-950/30"
: "text-white/90 hover:bg-gradient-to-r hover:from-cyan-500 hover:to-violet-500 hover:text-white"
}`}
> >
{category.label} {root.name}
</Link> </Link>
); ),
})} )}
</div> </div>
</nav> </nav>
); );
} }
function DesktopCategoryNavigationFallback({ onOpenMenu }: { onOpenMenu: () => void }) { function DesktopCategoryNavigationFallback({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
return ( return (
<nav className="border-b-4 border-fuchsia-500 bg-slate-950 text-white"> <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 overflow-x-auto px-4 py-2 [scrollbar-width:none]">
@@ -202,14 +228,13 @@ function DesktopCategoryNavigationFallback({ onOpenMenu }: { onOpenMenu: () => v
<Menu /> <Menu />
</Button> </Button>
{categories.map((category) => ( <Link href="/catalog" prefetch={false} className={pillClass(false)}>
<Link Todo
key={category.label} </Link>
href={category.href}
prefetch={false} {tree.map((root) => (
className="shrink-0 rounded-full px-4 py-2 text-sm font-extrabold text-white/90 transition hover:bg-gradient-to-r hover:from-cyan-500 hover:to-violet-500 hover:text-white" <Link key={root.id} href={`/catalog?category=${encodeURIComponent(root.name)}`} prefetch={false} className={pillClass(false)}>
> {root.name}
{category.label}
</Link> </Link>
))} ))}
</div> </div>
@@ -217,50 +242,75 @@ function DesktopCategoryNavigationFallback({ onOpenMenu }: { onOpenMenu: () => v
); );
} }
function MobileCategoryLinks({ onNavigate }: { onNavigate: () => void }) { function MobileCategoryLinks({ tree, onNavigate }: { tree: CategoryNode[]; onNavigate: () => void }) {
const { isCategoryActive } = useCategoryNavigationState(); const { isActive, isAllActive } = useCategoryNavigationState();
return ( return (
<> <>
{categories.map((category) => { <Link
const active = isCategoryActive(category); href="/catalog"
prefetch={false}
aria-current={isAllActive ? "page" : undefined}
onClick={onNavigate}
className={mobileLinkClass(isAllActive)}
>
Todo el catálogo
</Link>
return ( {tree.map((root) => (
<div key={root.id} className="contents">
<Link <Link
key={category.label} href={`/catalog?category=${encodeURIComponent(root.name)}`}
href={category.href}
prefetch={false} prefetch={false}
aria-current={active ? "page" : undefined} aria-current={isActive(root.name) ? "page" : undefined}
onClick={onNavigate} onClick={onNavigate}
className={`rounded-2xl border px-4 py-3 font-extrabold transition ${ className={mobileLinkClass(isActive(root.name))}
active
? "border-violet-500 bg-violet-50 text-violet-700"
: "border-slate-200 hover:border-cyan-400 hover:bg-cyan-50"
}`}
> >
{category.label} {root.name}
</Link> </Link>
); {root.children.map((child) => (
})} <Link
</> key={child.id}
); href={`/catalog?category=${encodeURIComponent(child.name)}`}
} prefetch={false}
aria-current={isActive(child.name) ? "page" : undefined}
function MobileCategoryLinksFallback({ onNavigate }: { onNavigate: () => void }) { onClick={onNavigate}
return ( className={`${mobileLinkClass(isActive(child.name))} ml-4 text-sm`}
<> >
{categories.map((category) => ( {child.name}
<Link </Link>
key={category.label} ))}
href={category.href} </div>
prefetch={false}
onClick={onNavigate}
className="rounded-2xl border border-slate-200 px-4 py-3 font-extrabold transition hover:border-cyan-400 hover:bg-cyan-50"
>
{category.label}
</Link>
))} ))}
</> </>
); );
} }
function MobileCategoryLinksFallback({ tree, onNavigate }: { tree: CategoryNode[]; onNavigate: () => void }) {
return (
<>
<Link href="/catalog" prefetch={false} onClick={onNavigate} className={mobileLinkClass(false)}>
Todo el catálogo
</Link>
{tree.map((root) => (
<div key={root.id} className="contents">
<Link href={`/catalog?category=${encodeURIComponent(root.name)}`} prefetch={false} onClick={onNavigate} className={mobileLinkClass(false)}>
{root.name}
</Link>
{root.children.map((child) => (
<Link
key={child.id}
href={`/catalog?category=${encodeURIComponent(child.name)}`}
prefetch={false}
onClick={onNavigate}
className={`${mobileLinkClass(false)} ml-4 text-sm`}
>
{child.name}
</Link>
))}
</div>
))}
</>
);
}
@@ -25,7 +25,7 @@ spec:
runAsUser: 1001 runAsUser: 1001
containers: containers:
- name: app - name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.129 image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.135
env: env:
- name: HEADLESS_PROVIDER - name: HEADLESS_PROVIDER
value: medusa value: medusa
@@ -64,9 +64,20 @@ spec:
# binario de node para esto, pero eso solo funciona si el # binario de node para esto, pero eso solo funciona si el
# bounding set del contenedor lo permite -- drop: ALL del base # bounding set del contenedor lo permite -- drop: ALL del base
# lo bloquea igual, root o no root. Mismo motivo que docs-portal. # lo bloquea igual, root o no root. Mismo motivo que docs-portal.
#
# readOnlyRootFilesystem: true -- revisado en código (2026-08-24,
# aún no probado en vivo con este cambio) que esta imagen sí
# escribe a disco en runtime, no solo en build: el servidor
# standalone de Next.js cachea a ./.next/cache tanto el
# optimizador de imágenes (next.config.ts trae remotePatterns,
# sin "unoptimized: true") como el revalidateTag("medusa-products")
# de app/api/revalidate/route.ts (cache handler por default, sin
# uno custom tipo Redis). De ahí los dos volumeMounts de abajo:
# sin ellos, con root filesystem de solo lectura, ambas rutas
# fallarían al intentar escribir.
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: false readOnlyRootFilesystem: true
capabilities: capabilities:
drop: drop:
- ALL - ALL
@@ -79,3 +90,13 @@ spec:
limits: limits:
cpu: "1" cpu: "1"
memory: 768Mi memory: 768Mi
volumeMounts:
- name: next-cache
mountPath: /app/.next/cache
- name: tmp
mountPath: /tmp
volumes:
- name: next-cache
emptyDir: {}
- name: tmp
emptyDir: {}
@@ -22,3 +22,7 @@ export async function getProductBySlug(slug: string) {
export async function searchProducts(query: string, limit?: number) { export async function searchProducts(query: string, limit?: number) {
return provider().searchProducts(query, limit); return provider().searchProducts(query, limit);
} }
export async function getCategories() {
return provider().getCategories();
}
@@ -1,5 +1,6 @@
import type { import type {
CatalogProvider, CatalogProvider,
Category,
Product, Product,
ProductFilters, ProductFilters,
ProductVariant, ProductVariant,
@@ -66,6 +67,18 @@ type MedusaProductsResponse = {
offset?: number; offset?: number;
}; };
type MedusaProductCategory = {
id: string;
name?: string | null;
handle?: string | null;
parent_category_id?: string | null;
rank?: number | null;
};
type MedusaCategoriesResponse = {
product_categories: MedusaProductCategory[];
};
function backendUrl(): string { function backendUrl(): string {
const value = process.env.MEDUSA_BACKEND_URL; const value = process.env.MEDUSA_BACKEND_URL;
if (!value) { if (!value) {
@@ -339,11 +352,40 @@ async function fetchProducts(
.filter((product) => matchesFilters(product, filters)); .filter((product) => matchesFilters(product, filters));
} }
async function fetchCategories(): Promise<Category[]> {
const params = new URLSearchParams({
limit: "100",
fields: "id,name,handle,rank,parent_category_id",
});
const response = await medusaFetch<MedusaCategoriesResponse>(
"/store/product-categories",
params,
);
// Ordenar por rank preserva el orden de la taxonomia tal como se creo
// (ver ADR 0005) -- los hijos de cada padre quedan en el mismo orden
// relativo porque el arbol se arma filtrando este arreglo ya ordenado.
return response.product_categories
.slice()
.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))
.map((category) => ({
id: category.id,
name: category.name || "",
handle: category.handle || category.id,
parentId: category.parent_category_id ?? null,
}));
}
export const medusaCatalogProvider: CatalogProvider = { export const medusaCatalogProvider: CatalogProvider = {
async getProducts(filters) { async getProducts(filters) {
return fetchProducts(filters); return fetchProducts(filters);
}, },
async getCategories() {
return fetchCategories();
},
async getProductBySlug(slug) { async getProductBySlug(slug) {
const params = new URLSearchParams({ const params = new URLSearchParams({
handle: slug, handle: slug,
@@ -1,5 +1,5 @@
import catalog from "@/data/products.json"; import catalog from "@/data/products.json";
import type { CatalogProvider, Product, ProductFilters } from "@/lib/headless/types"; import type { CatalogProvider, Category, Product, ProductFilters } from "@/lib/headless/types";
const products = catalog satisfies Product[]; const products = catalog satisfies Product[];
@@ -39,4 +39,16 @@ export const mockCatalogProvider: CatalogProvider = {
async searchProducts(query, limit = 6) { async searchProducts(query, limit = 6) {
return products.filter((product) => matches(product, { query })).slice(0, limit); return products.filter((product) => matches(product, { query })).slice(0, limit);
}, },
async getCategories(): Promise<Category[]> {
// El catalogo mock no tiene jerarquia real (a diferencia de Medusa) --
// se devuelve una lista plana de las categorias que ya usan los
// productos mock, todas sin padre.
const names = [...new Set(products.map((product) => product.category))].filter(Boolean);
return names.map((name) => ({
id: name,
name,
handle: normalize(name).replace(/\s+/g, "-"),
parentId: null,
}));
},
}; };
@@ -42,4 +42,7 @@ export const shopifyCatalogProvider: CatalogProvider = {
async searchProducts(): Promise<Product[]> { async searchProducts(): Promise<Product[]> {
return notImplemented(); return notImplemented();
}, },
async getCategories() {
return notImplemented();
},
}; };
@@ -39,8 +39,16 @@ export interface ProductFilters {
maxPrice?: number; maxPrice?: number;
} }
export interface Category {
id: string;
name: string;
handle: string;
parentId: string | null;
}
export interface CatalogProvider { export interface CatalogProvider {
getProducts(filters?: ProductFilters): Promise<Product[]>; getProducts(filters?: ProductFilters): Promise<Product[]>;
getProductBySlug(slug: string): Promise<Product | null>; getProductBySlug(slug: string): Promise<Product | null>;
searchProducts(query: string, limit?: number): Promise<Product[]>; searchProducts(query: string, limit?: number): Promise<Product[]>;
getCategories(): Promise<Category[]>;
} }