Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7443a034f | ||
|
|
4ee892a43a | ||
|
|
c6c66b76a3 | ||
|
|
7406aff34f | ||
|
|
ba3e6c7322 | ||
|
|
884dbd1d93 | ||
|
|
951682a4d4 | ||
|
|
6b5450f16f | ||
|
|
83d448b282 | ||
|
|
1ecd2015a4 | ||
|
|
609fc19252 | ||
|
|
902350b58b | ||
|
|
7d2c03f10f | ||
|
|
72ae63f2e3 | ||
|
|
ed9891577f |
No files matched your search
@@ -282,7 +282,7 @@ contra estos 9 productos demo:
|
|||||||
intercalados en la grilla (PR #41, con fix-forward de tipos en
|
intercalados en la grilla (PR #41, con fix-forward de tipos en
|
||||||
PR #42).
|
PR #42).
|
||||||
|
|
||||||
**Pendiente:** borrar el usuario admin temporal `[email protected]`
|
El usuario admin temporal `[email protected]` (creado para la
|
||||||
de Medusa Admin (creado para la auditoría de la sección 5, ya no
|
auditoría de la sección 5) fue borrado el 2026-08-27 vía Medusa Admin.
|
||||||
necesario). Sin próxima etapa definida todavía — este ADR no
|
Sin próxima etapa definida todavía — este ADR no especifica una
|
||||||
especifica una Etapa 3.
|
Etapa 3.
|
||||||
@@ -25,7 +25,7 @@ spec:
|
|||||||
runAsNonRoot: false
|
runAsNonRoot: false
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.164
|
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.173
|
||||||
# El base saca TODAS las capabilities. Probado en vivo y
|
# El base saca TODAS las capabilities. Probado en vivo y
|
||||||
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
|
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
|
||||||
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
|
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { searchWithFacets } from "@/lib/headless/client";
|
||||||
|
import type { ProductFilters } from "@/lib/headless/types";
|
||||||
|
|
||||||
|
// PredictiveSearch es un componente cliente y no puede pegarle directo
|
||||||
|
// al provider server-side (MEDUSA_BACKEND_URL es un nombre DNS interno
|
||||||
|
// del cluster, no resuelve desde el navegador) -- esta ruta es el
|
||||||
|
// puente same-origin.
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const filters: ProductFilters = {
|
||||||
|
query: searchParams.get("q") ?? undefined,
|
||||||
|
category: searchParams.get("category") ?? undefined,
|
||||||
|
collection: searchParams.get("collection") ?? undefined,
|
||||||
|
brand: searchParams.get("brand") ?? undefined,
|
||||||
|
ageRange: searchParams.get("ageRange") ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const minPrice = searchParams.get("minPrice");
|
||||||
|
if (minPrice) filters.minPrice = Number(minPrice);
|
||||||
|
const maxPrice = searchParams.get("maxPrice");
|
||||||
|
if (maxPrice) filters.maxPrice = Number(maxPrice);
|
||||||
|
const limit = searchParams.get("limit");
|
||||||
|
if (limit) filters.limit = Number(limit);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await searchWithFacets(filters);
|
||||||
|
// provider actual no implementa searchWithFacets (mock/shopify) --
|
||||||
|
// degradar a vacío, no a 500; el componente cliente cae de nuevo a
|
||||||
|
// su propio filtrado si esta ruta no trae nada útil.
|
||||||
|
return Response.json(
|
||||||
|
result ?? { products: [], facetDistribution: {}, count: 0 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
products: [],
|
||||||
|
facetDistribution: {},
|
||||||
|
count: 0,
|
||||||
|
error: (error as Error).message,
|
||||||
|
},
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ spec:
|
|||||||
memory: 64Mi
|
memory: 64Mi
|
||||||
|
|
||||||
- name: migrations
|
- name: migrations
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.167
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- node
|
- node
|
||||||
@@ -86,7 +86,7 @@ spec:
|
|||||||
|
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.167
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { PromoTile } from "@/components/home/category-tiles";
|
|||||||
import { ProductCard } from "@/components/product/product-card";
|
import { ProductCard } from "@/components/product/product-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import type { Category, Product } from "@/lib/headless/types";
|
import type { Category, FacetDistribution, Product } from "@/lib/headless/types";
|
||||||
|
|
||||||
// Cada cuantas tarjetas de producto se intercala un tile promocional en
|
// Cada cuantas tarjetas de producto se intercala un tile promocional en
|
||||||
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
|
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
|
||||||
@@ -156,8 +156,10 @@ export function CatalogClient({
|
|||||||
// Conteos por valor de filtro (patron Amazon: "Marca (12)"), calculados
|
// Conteos por valor de filtro (patron Amazon: "Marca (12)"), calculados
|
||||||
// sobre los productos que ya pasan el resto de filtros activos -- cada
|
// sobre los productos que ya pasan el resto de filtros activos -- cada
|
||||||
// dimension se cuenta excluyendose a si misma para reflejar cuantos
|
// dimension se cuenta excluyendose a si misma para reflejar cuantos
|
||||||
// resultados habria si se eligiera ese valor.
|
// resultados habria si se eligiera ese valor. Sirven de fallback cuando
|
||||||
const categoryCounts = useMemo(() => {
|
// el provider activo no soporta facetas reales (mock/shopify) o cuando
|
||||||
|
// la busqueda en vivo todavia no respondio.
|
||||||
|
const fallbackCategoryCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice),
|
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice),
|
||||||
);
|
);
|
||||||
@@ -170,7 +172,7 @@ export function CatalogClient({
|
|||||||
return counts;
|
return counts;
|
||||||
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
|
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
|
||||||
|
|
||||||
const brandCounts = useMemo(() => {
|
const fallbackBrandCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
(product) =>
|
(product) =>
|
||||||
matchesQuery(product, query) &&
|
matchesQuery(product, query) &&
|
||||||
@@ -187,7 +189,7 @@ export function CatalogClient({
|
|||||||
return counts;
|
return counts;
|
||||||
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
|
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
|
||||||
|
|
||||||
const ageCounts = useMemo(() => {
|
const fallbackAgeCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
(product) =>
|
(product) =>
|
||||||
matchesQuery(product, query) &&
|
matchesQuery(product, query) &&
|
||||||
@@ -204,6 +206,108 @@ export function CatalogClient({
|
|||||||
return counts;
|
return counts;
|
||||||
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]);
|
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]);
|
||||||
|
|
||||||
|
// "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe
|
||||||
|
// como valor indexado en Meilisearch (ningun producto lo tiene como
|
||||||
|
// category real, solo sus hijos -- ver categoryGroups arriba). Mientras
|
||||||
|
// esa sea la categoria activa, las facetas en vivo de marca/edad no
|
||||||
|
// pueden filtrar por ella y se degrada al conteo client-side, que si
|
||||||
|
// sabe expandir el grupo via matchesCategory/allowedCategories.
|
||||||
|
const categoryIsLeafOrNone = category === "Todos" || !categoryGroups[category];
|
||||||
|
|
||||||
|
// Conteos reales via Meilisearch (ADR 0006 Etapa 2 Fase 3b): un fetch
|
||||||
|
// por dimension contra /api/search, cada uno omitiendo su propio filtro
|
||||||
|
// (mismo criterio de auto-exclusion que el fallback de arriba) para que
|
||||||
|
// los conteos reflejen el subconjunto ya filtrado por las OTRAS
|
||||||
|
// dimensiones y se puedan seguir eligiendo otros valores. `limit=0` evita
|
||||||
|
// rehidratar precios de productos que no vamos a mostrar -- solo importan
|
||||||
|
// `facetDistribution` y `count`.
|
||||||
|
const [liveFacets, setLiveFacets] = useState<{
|
||||||
|
category: Record<string, number> | null;
|
||||||
|
categoryTotal: number | null;
|
||||||
|
brand: Record<string, number> | null;
|
||||||
|
brandTotal: number | null;
|
||||||
|
ageRange: Record<string, number> | null;
|
||||||
|
ageTotal: number | null;
|
||||||
|
}>({ category: null, categoryTotal: null, brand: null, brandTotal: null, ageRange: null, ageTotal: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
function buildParams(omit: "category" | "brand" | "ageRange"): URLSearchParams {
|
||||||
|
const params = new URLSearchParams({ limit: "0" });
|
||||||
|
if (query) params.set("q", query);
|
||||||
|
if (collection) params.set("collection", collection);
|
||||||
|
if (omit !== "category" && category !== "Todos" && categoryIsLeafOrNone) {
|
||||||
|
params.set("category", category);
|
||||||
|
}
|
||||||
|
if (omit !== "brand" && brand !== "Todas") params.set("brand", brand);
|
||||||
|
if (omit !== "ageRange" && age !== "Todas") params.set("ageRange", age);
|
||||||
|
if (maxPrice > 0) params.set("maxPrice", String(maxPrice));
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFacet(omit: "category" | "brand" | "ageRange") {
|
||||||
|
const response = await fetch(`/api/search?${buildParams(omit).toString()}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
return (await response.json()) as { facetDistribution?: FacetDistribution };
|
||||||
|
}
|
||||||
|
|
||||||
|
// El "count"/estimatedTotalHits que devuelve /api/search viene en 0 con
|
||||||
|
// limit=0 (confirmado en vivo contra Meilisearch real -- limit>=1 lo
|
||||||
|
// reporta bien, limit=0 no). facetDistribution si es correcto con
|
||||||
|
// limit=0, y cada producto tiene siempre un valor para cada dimension
|
||||||
|
// (mapProduct usa defaults como "ARI Shopping"/"Todas", nunca null), asi
|
||||||
|
// que sumar sus valores da el total real sin depender de ese campo.
|
||||||
|
function sumFacetValues(facet: Record<string, number> | null): number {
|
||||||
|
return facet ? Object.values(facet).reduce((sum, value) => sum + value, 0) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
Promise.all([fetchFacet("category"), fetchFacet("brand"), fetchFacet("ageRange")])
|
||||||
|
.then(([categoryRes, brandRes, ageRes]) => {
|
||||||
|
// Un facetDistribution vacio ({}) es la senal de degrade explicita
|
||||||
|
// de /api/search (provider sin searchWithFacets, o Meilisearch
|
||||||
|
// sin indices todavia) -- distinto de una faceta real con 0
|
||||||
|
// resultados, que igual trae la clave presente.
|
||||||
|
const categoryFacet = categoryRes.facetDistribution && "category" in categoryRes.facetDistribution ? categoryRes.facetDistribution.category : null;
|
||||||
|
const brandFacet = brandRes.facetDistribution && "brand" in brandRes.facetDistribution ? brandRes.facetDistribution.brand : null;
|
||||||
|
const ageFacet = ageRes.facetDistribution && "ageRange" in ageRes.facetDistribution ? ageRes.facetDistribution.ageRange : null;
|
||||||
|
setLiveFacets({
|
||||||
|
category: categoryFacet,
|
||||||
|
categoryTotal: categoryFacet ? sumFacetValues(categoryFacet) : null,
|
||||||
|
brand: categoryIsLeafOrNone ? brandFacet : null,
|
||||||
|
brandTotal: categoryIsLeafOrNone && brandFacet ? sumFacetValues(brandFacet) : null,
|
||||||
|
ageRange: categoryIsLeafOrNone ? ageFacet : null,
|
||||||
|
ageTotal: categoryIsLeafOrNone && ageFacet ? sumFacetValues(ageFacet) : null,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if ((error as Error).name !== "AbortError") {
|
||||||
|
setLiveFacets({ category: null, categoryTotal: null, brand: null, brandTotal: null, ageRange: null, ageTotal: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]);
|
||||||
|
|
||||||
|
const categoryCounts = useMemo(
|
||||||
|
() => (liveFacets.category ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
|
||||||
|
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal],
|
||||||
|
);
|
||||||
|
const brandCounts = useMemo(
|
||||||
|
() => (liveFacets.brand ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
|
||||||
|
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal],
|
||||||
|
);
|
||||||
|
const ageCounts = useMemo(
|
||||||
|
() => (liveFacets.ageRange ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
|
||||||
|
[fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal],
|
||||||
|
);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setCategory("Todos");
|
setCategory("Todos");
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
<Link href="/" className="relative size-16 overflow-hidden rounded-full border-2 border-slate-900 bg-black shadow-lg lg:size-20">
|
<Link href="/" className="relative size-16 overflow-hidden rounded-full border-2 border-slate-900 bg-black shadow-lg lg:size-20">
|
||||||
<Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill priority sizes="80px" className="object-cover" />
|
<Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill priority sizes="80px" className="object-cover" />
|
||||||
</Link>
|
</Link>
|
||||||
<PredictiveSearch products={products} />
|
<PredictiveSearch />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="icon" aria-label="Mi cuenta" className="hidden border-slate-300 bg-white text-slate-900 hover:border-violet-400 hover:bg-violet-50 sm:inline-flex" onClick={() => setAccountOpen(true)}><UserRound /></Button>
|
<Button variant="outline" size="icon" aria-label="Mi cuenta" className="hidden border-slate-300 bg-white text-slate-900 hover:border-violet-400 hover:bg-violet-50 sm:inline-flex" onClick={() => setAccountOpen(true)}><UserRound /></Button>
|
||||||
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-fuchsia-400 hover:bg-fuchsia-50 sm:inline-flex" onClick={openWishlist}>
|
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-fuchsia-400 hover:bg-fuchsia-50 sm:inline-flex" onClick={openWishlist}>
|
||||||
|
|||||||
@@ -4,20 +4,45 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { FormEvent, useMemo, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import type { Product } from "@/lib/headless/types";
|
import type { Product } from "@/lib/headless/types";
|
||||||
|
|
||||||
export function PredictiveSearch({ products }: { products: Product[] }) {
|
export function PredictiveSearch() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const normalized = query.trim().toLocaleLowerCase("es");
|
const [matches, setMatches] = useState<Product[]>([]);
|
||||||
const matches = useMemo(
|
const [loading, setLoading] = useState(false);
|
||||||
() => normalized.length < 2
|
const normalized = query.trim();
|
||||||
? []
|
|
||||||
: products.filter((product) => [product.name, product.category, product.collection, product.brand].join(" ").toLocaleLowerCase("es").includes(normalized)).slice(0, 6),
|
useEffect(() => {
|
||||||
[normalized, products],
|
if (normalized.length < 2) {
|
||||||
);
|
setMatches([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fetch(`/api/search?q=${encodeURIComponent(normalized)}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data: { products?: Product[] }) => {
|
||||||
|
setMatches((data.products ?? []).slice(0, 6));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if ((error as Error).name !== "AbortError") setMatches([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [normalized]);
|
||||||
|
|
||||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -36,6 +61,11 @@ export function PredictiveSearch({ products }: { products: Product[] }) {
|
|||||||
{query && <button type="button" aria-label="Limpiar búsqueda" className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 hover:bg-slate-100" onClick={() => setQuery("")}><X className="size-4" /></button>}
|
{query && <button type="button" aria-label="Limpiar búsqueda" className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 hover:bg-slate-100" onClick={() => setQuery("")}><X className="size-4" /></button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{loading && normalized.length >= 2 && matches.length === 0 && (
|
||||||
|
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 rounded-2xl border border-slate-200 bg-white p-4 text-center text-sm text-slate-500 shadow-2xl">
|
||||||
|
Buscando…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{matches.length > 0 && (
|
{matches.length > 0 && (
|
||||||
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 overflow-hidden rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl">
|
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 overflow-hidden rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl">
|
||||||
{matches.map((product) => (
|
{matches.map((product) => (
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ spec:
|
|||||||
runAsUser: 1001
|
runAsUser: 1001
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.157
|
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.172
|
||||||
env:
|
env:
|
||||||
- name: HEADLESS_PROVIDER
|
- name: HEADLESS_PROVIDER
|
||||||
value: medusa
|
value: medusa
|
||||||
|
|||||||
@@ -26,3 +26,7 @@ export async function searchProducts(query: string, limit?: number) {
|
|||||||
export async function getCategories() {
|
export async function getCategories() {
|
||||||
return provider().getCategories();
|
return provider().getCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function searchWithFacets(filters?: ProductFilters) {
|
||||||
|
return provider().searchWithFacets?.(filters);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
CatalogProvider,
|
CatalogProvider,
|
||||||
Category,
|
Category,
|
||||||
|
FacetDistribution,
|
||||||
|
FacetedSearchResult,
|
||||||
Product,
|
Product,
|
||||||
ProductFilters,
|
ProductFilters,
|
||||||
ProductVariant,
|
ProductVariant,
|
||||||
@@ -385,6 +387,56 @@ async function fetchCategories(): Promise<Category[]> {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MedusaSearchResponse = {
|
||||||
|
products: MedusaProduct[];
|
||||||
|
facetDistribution?: FacetDistribution;
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pega contra /store/search (ADR 0006 Etapa 2) en vez de /store/products
|
||||||
|
// -- Meilisearch resuelve texto tolerante a errores de tipeo y facetas
|
||||||
|
// reales, la ruta del backend re-hidrata precio/inventario vía
|
||||||
|
// query.graph antes de responder, así que el shape de MedusaProduct es
|
||||||
|
// exactamente el mismo que ya mapea mapProduct().
|
||||||
|
async function fetchWithFacets(
|
||||||
|
filters?: ProductFilters,
|
||||||
|
limit = 24,
|
||||||
|
): Promise<FacetedSearchResult> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: String(limit),
|
||||||
|
region_id: regionId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filters?.query) params.set("q", filters.query);
|
||||||
|
if (filters?.category && filters.category !== "Todos") {
|
||||||
|
params.set("category", filters.category);
|
||||||
|
}
|
||||||
|
if (filters?.collection) params.set("collection", filters.collection);
|
||||||
|
if (filters?.brand && filters.brand !== "Todas") {
|
||||||
|
params.set("brand", filters.brand);
|
||||||
|
}
|
||||||
|
if (filters?.ageRange && filters.ageRange !== "Todas") {
|
||||||
|
params.set("ageRange", filters.ageRange);
|
||||||
|
}
|
||||||
|
if (typeof filters?.minPrice === "number") {
|
||||||
|
params.set("minPrice", String(filters.minPrice));
|
||||||
|
}
|
||||||
|
if (typeof filters?.maxPrice === "number") {
|
||||||
|
params.set("maxPrice", String(filters.maxPrice));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await medusaFetch<MedusaSearchResponse>(
|
||||||
|
"/store/search",
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
products: response.products.map(mapProduct),
|
||||||
|
facetDistribution: response.facetDistribution ?? {},
|
||||||
|
count: response.count ?? response.products.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const medusaCatalogProvider: CatalogProvider = {
|
export const medusaCatalogProvider: CatalogProvider = {
|
||||||
async getProducts(filters) {
|
async getProducts(filters) {
|
||||||
return fetchProducts(filters);
|
return fetchProducts(filters);
|
||||||
@@ -416,6 +468,11 @@ export const medusaCatalogProvider: CatalogProvider = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async searchProducts(query, limit = 8) {
|
async searchProducts(query, limit = 8) {
|
||||||
return fetchProducts({ query }, limit);
|
const { products } = await fetchWithFacets({ query }, limit);
|
||||||
|
return products;
|
||||||
|
},
|
||||||
|
|
||||||
|
async searchWithFacets(filters) {
|
||||||
|
return fetchWithFacets(filters, filters?.limit ?? 24);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -38,6 +38,10 @@ export interface ProductFilters {
|
|||||||
ageRange?: string;
|
ageRange?: string;
|
||||||
minPrice?: number;
|
minPrice?: number;
|
||||||
maxPrice?: number;
|
maxPrice?: number;
|
||||||
|
// Solo lo usa searchWithFacets -- limit=0 pide facetDistribution/count
|
||||||
|
// sin rehidratar precios de productos que no se van a mostrar (ver
|
||||||
|
// sidebar de facetas en catalog-client.tsx).
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
@@ -47,9 +51,23 @@ export interface Category {
|
|||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// facet -> valor -> cuantos productos del resultado filtrado tienen ese
|
||||||
|
// valor (ej. { brand: { Marvel: 5, Disney: 10 } }).
|
||||||
|
export type FacetDistribution = Record<string, Record<string, number>>;
|
||||||
|
|
||||||
|
export interface FacetedSearchResult {
|
||||||
|
products: Product[];
|
||||||
|
facetDistribution: FacetDistribution;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CatalogProvider {
|
export interface CatalogProvider {
|
||||||
getProducts(filters?: ProductFilters): Promise<Product[]>;
|
getProducts(filters?: ProductFilters): Promise<Product[]>;
|
||||||
getProductBySlug(slug: string): Promise<Product | null>;
|
getProductBySlug(slug: string): Promise<Product | null>;
|
||||||
searchProducts(query: string, limit?: number): Promise<Product[]>;
|
searchProducts(query: string, limit?: number): Promise<Product[]>;
|
||||||
getCategories(): Promise<Category[]>;
|
getCategories(): Promise<Category[]>;
|
||||||
|
// Opcional a propósito: mock.ts y shopify.ts no lo implementan y
|
||||||
|
// siguen compilando igual -- los llamadores caen de nuevo al
|
||||||
|
// filtrado/conteo 100% client-side que ya existe cuando no está.
|
||||||
|
searchWithFacets?(filters?: ProductFilters): Promise<FacetedSearchResult>;
|
||||||
}
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: frontend-deploy
|
|
||||||
spec:
|
|
||||||
template:
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: web
|
|
||||||
env:
|
|
||||||
- name: HEADLESS_PROVIDER
|
|
||||||
value: medusa
|
|
||||||
|
|
||||||
- name: MEDUSA_BACKEND_URL
|
|
||||||
value: http://medusa-svc:9000
|
|
||||||
|
|
||||||
- name: MEDUSA_PUBLISHABLE_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: commerce-storefront
|
|
||||||
key: MEDUSA_PUBLISHABLE_KEY
|
|
||||||
Reference in new issue
Block a user