@arroyavecommerce/theme-kinetic 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/design/README.md +22 -0
- package/docs/design/kinetic-street-concept.html +558 -0
- package/package.json +45 -0
- package/src/blocks/_shared.tsx +174 -0
- package/src/blocks/carrusel.tsx +109 -0
- package/src/blocks/category-tiles.test.tsx +59 -0
- package/src/blocks/category-tiles.tsx +70 -0
- package/src/blocks/editorial-banner.test.tsx +96 -0
- package/src/blocks/editorial-banner.tsx +205 -0
- package/src/blocks/faq.test.tsx +68 -0
- package/src/blocks/faq.tsx +122 -0
- package/src/blocks/featured-collection.test.tsx +80 -0
- package/src/blocks/featured-collection.tsx +97 -0
- package/src/blocks/features.test.tsx +71 -0
- package/src/blocks/features.tsx +159 -0
- package/src/blocks/hero.test.tsx +75 -0
- package/src/blocks/hero.tsx +290 -0
- package/src/blocks/index.tsx +35 -0
- package/src/blocks/lookbook.test.tsx +80 -0
- package/src/blocks/lookbook.tsx +117 -0
- package/src/blocks/marquee.test.tsx +54 -0
- package/src/blocks/marquee.tsx +114 -0
- package/src/blocks/newsletter.test.tsx +60 -0
- package/src/blocks/newsletter.tsx +105 -0
- package/src/blocks/product-grid.test.tsx +160 -0
- package/src/blocks/product-grid.tsx +76 -0
- package/src/blocks/promo-countdown.tsx +75 -0
- package/src/blocks/promo.test.tsx +71 -0
- package/src/blocks/promo.tsx +120 -0
- package/src/blocks/rich-text.test.tsx +67 -0
- package/src/blocks/rich-text.tsx +75 -0
- package/src/blocks/spacer.test.tsx +54 -0
- package/src/blocks/spacer.tsx +56 -0
- package/src/blocks/testimonials.test.tsx +74 -0
- package/src/blocks/testimonials.tsx +104 -0
- package/src/blocks/video.test.tsx +159 -0
- package/src/blocks/video.tsx +226 -0
- package/src/catalog/buy-box.test.tsx +225 -0
- package/src/catalog/buy-box.tsx +353 -0
- package/src/catalog/config.test.ts +115 -0
- package/src/catalog/config.ts +167 -0
- package/src/catalog/filter-controls.test.tsx +149 -0
- package/src/catalog/filter-controls.tsx +187 -0
- package/src/catalog/gallery.test.tsx +201 -0
- package/src/catalog/gallery.tsx +212 -0
- package/src/catalog/lightbox.test.tsx +95 -0
- package/src/catalog/lightbox.tsx +184 -0
- package/src/catalog/pdp.test.tsx +309 -0
- package/src/catalog/pdp.tsx +354 -0
- package/src/catalog/plp-arreglos.test.tsx +235 -0
- package/src/catalog/plp-inline.tsx +54 -0
- package/src/catalog/plp-overlay.tsx +129 -0
- package/src/catalog/plp-parts.test.tsx +146 -0
- package/src/catalog/plp-parts.tsx +292 -0
- package/src/catalog/plp-pills.tsx +44 -0
- package/src/catalog/plp-sidebar.tsx +47 -0
- package/src/catalog/plp.test.tsx +319 -0
- package/src/catalog/plp.tsx +259 -0
- package/src/catalog/product-card.test.tsx +221 -0
- package/src/catalog/product-card.tsx +411 -0
- package/src/catalog/registry.tsx +16 -0
- package/src/catalog/related.test.tsx +52 -0
- package/src/catalog/related.tsx +40 -0
- package/src/catalog/reviews.test.tsx +56 -0
- package/src/catalog/reviews.tsx +116 -0
- package/src/catalog/skeletons.test.tsx +61 -0
- package/src/catalog/skeletons.tsx +103 -0
- package/src/chrome/announcement-bar.test.tsx +86 -0
- package/src/chrome/announcement-bar.tsx +173 -0
- package/src/chrome/drawer-movil.test.tsx +113 -0
- package/src/chrome/drawer-movil.tsx +342 -0
- package/src/chrome/footer.test.tsx +133 -0
- package/src/chrome/footer.tsx +603 -0
- package/src/chrome/header.test.tsx +165 -0
- package/src/chrome/header.tsx +592 -0
- package/src/chrome/index.ts +26 -0
- package/src/chrome/mega-menu.test.tsx +161 -0
- package/src/chrome/mega-menu.tsx +317 -0
- package/src/chrome/mini-cart.test.tsx +111 -0
- package/src/chrome/mini-cart.tsx +320 -0
- package/src/content/home.ts +19 -0
- package/src/content/seed.test.ts +33 -0
- package/src/content/seed.ts +28 -0
- package/src/context.ts +104 -0
- package/src/icons/icons.test.tsx +118 -0
- package/src/icons/index.ts +136 -0
- package/src/icons/payment-icons.tsx +241 -0
- package/src/icons/social-icons.tsx +108 -0
- package/src/icons/svg-base.tsx +68 -0
- package/src/index.ts +49 -0
- package/src/puck-config.tsx +404 -0
- package/src/smoke.test.ts +8 -0
- package/src/template.test.tsx +88 -0
- package/src/test/mock-ctx.test.tsx +77 -0
- package/src/test/mock-ctx.tsx +236 -0
- package/src/theme-defaults.test.ts +9 -0
- package/src/theme.test.ts +82 -0
- package/src/theme.ts +195 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { CmsPageConfig, PageConfigSetting } from "@arroyavecommerce/cms-core"
|
|
2
|
+
import { resolvePageConfig } from "@arroyavecommerce/cms-storefront"
|
|
3
|
+
import type { PlpConfig } from "./plp"
|
|
4
|
+
import type { PdpConfig } from "./pdp"
|
|
5
|
+
|
|
6
|
+
/** Devuelve `valor` si pertenece a `opciones`, si no el `fallback` (mismo criterio defensivo que el dispatcher `?? ArregloSidebar` de 4d-ii). */
|
|
7
|
+
function enOEnum<T extends string>(valor: string, opciones: readonly T[], fallback: T): T {
|
|
8
|
+
return (opciones as readonly string[]).includes(valor) ? (valor as T) : fallback
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** `"si"` -> true, cualquier otro string -> el `fallback` booleano de esa clave. */
|
|
12
|
+
function enOBooleano(valor: string, fallback: boolean): boolean {
|
|
13
|
+
return valor === "si" ? true : valor === "no" ? false : fallback
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `CmsPageConfig` (strings) -> `PlpConfig` (tipado), con coerción + fallback defensivo por clave. */
|
|
17
|
+
export function resolverPlpConfig(pageConfig: CmsPageConfig | null): PlpConfig {
|
|
18
|
+
const filtros = enOEnum(
|
|
19
|
+
resolvePageConfig(pageConfig, "plp.filtros", "sidebar"),
|
|
20
|
+
["sidebar", "pills", "overlay", "inline"] as const,
|
|
21
|
+
"sidebar"
|
|
22
|
+
)
|
|
23
|
+
const columnasRaw = resolvePageConfig(pageConfig, "plp.columnas", "3")
|
|
24
|
+
const columnas = columnasRaw === "3" || columnasRaw === "4" ? (Number(columnasRaw) as 3 | 4) : 3
|
|
25
|
+
const card = enOEnum(
|
|
26
|
+
resolvePageConfig(pageConfig, "plp.card", "minimal"),
|
|
27
|
+
["minimal", "quick-add", "tallas", "quick-view"] as const,
|
|
28
|
+
"minimal"
|
|
29
|
+
)
|
|
30
|
+
const paginacion = enOEnum(
|
|
31
|
+
resolvePageConfig(pageConfig, "plp.paginacion", "numerada"),
|
|
32
|
+
["numerada", "load-more"] as const,
|
|
33
|
+
"numerada"
|
|
34
|
+
)
|
|
35
|
+
const mostrarContador = enOBooleano(resolvePageConfig(pageConfig, "plp.mostrarContador", "si"), true)
|
|
36
|
+
const vista = enOEnum(
|
|
37
|
+
resolvePageConfig(pageConfig, "plp.vista", "grilla"),
|
|
38
|
+
["grilla", "lista"] as const,
|
|
39
|
+
"grilla"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return { filtros, columnas, card, paginacion, mostrarContador, vista }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `CmsPageConfig` (strings) -> `PdpConfig` (tipado), con coerción + fallback defensivo por clave. */
|
|
46
|
+
export function resolverPdpConfig(pageConfig: CmsPageConfig | null): PdpConfig {
|
|
47
|
+
const galeria = enOEnum(
|
|
48
|
+
resolvePageConfig(pageConfig, "pdp.galeria", "grid"),
|
|
49
|
+
["grid", "carrusel"] as const,
|
|
50
|
+
"grid"
|
|
51
|
+
)
|
|
52
|
+
const stickyBar = enOBooleano(resolvePageConfig(pageConfig, "pdp.stickyBar", "si"), true)
|
|
53
|
+
const compraRapida = enOBooleano(resolvePageConfig(pageConfig, "pdp.compraRapida", "no"), false)
|
|
54
|
+
const mostrarFavoritos = enOBooleano(resolvePageConfig(pageConfig, "pdp.mostrarFavoritos", "si"), true)
|
|
55
|
+
const mostrarReseñas = enOBooleano(resolvePageConfig(pageConfig, "pdp.mostrarReseñas", "no"), false)
|
|
56
|
+
const mostrarRelacionados = enOBooleano(resolvePageConfig(pageConfig, "pdp.mostrarRelacionados", "si"), true)
|
|
57
|
+
|
|
58
|
+
return { galeria, stickyBar, compraRapida, mostrarFavoritos, mostrarReseñas, mostrarRelacionados }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Descriptor de settings del PLP (spec §8) para la UI de config de página. */
|
|
62
|
+
export const plpSettings: PageConfigSetting[] = [
|
|
63
|
+
{
|
|
64
|
+
key: "plp.filtros",
|
|
65
|
+
label: "Filtros",
|
|
66
|
+
opciones: [
|
|
67
|
+
{ value: "sidebar", label: "Sidebar" },
|
|
68
|
+
{ value: "pills", label: "Pills" },
|
|
69
|
+
{ value: "overlay", label: "Overlay" },
|
|
70
|
+
{ value: "inline", label: "Inline" },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
key: "plp.columnas",
|
|
75
|
+
label: "Columnas",
|
|
76
|
+
opciones: [
|
|
77
|
+
{ value: "3", label: "3 columnas" },
|
|
78
|
+
{ value: "4", label: "4 columnas" },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: "plp.card",
|
|
83
|
+
label: "Tarjeta de producto",
|
|
84
|
+
opciones: [
|
|
85
|
+
{ value: "minimal", label: "Minimal" },
|
|
86
|
+
{ value: "quick-add", label: "Quick add" },
|
|
87
|
+
{ value: "tallas", label: "Tallas" },
|
|
88
|
+
{ value: "quick-view", label: "Quick view" },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: "plp.paginacion",
|
|
93
|
+
label: "Paginación",
|
|
94
|
+
opciones: [
|
|
95
|
+
{ value: "numerada", label: "Numerada" },
|
|
96
|
+
{ value: "load-more", label: "Cargar más" },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
key: "plp.mostrarContador",
|
|
101
|
+
label: "Mostrar contador de resultados",
|
|
102
|
+
opciones: [
|
|
103
|
+
{ value: "si", label: "Sí" },
|
|
104
|
+
{ value: "no", label: "No" },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: "plp.vista",
|
|
109
|
+
label: "Vista del listado",
|
|
110
|
+
opciones: [
|
|
111
|
+
{ value: "grilla", label: "Grilla" },
|
|
112
|
+
{ value: "lista", label: "Lista" },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
/** Descriptor de settings del PDP (spec §8) para la UI de config de página. */
|
|
118
|
+
export const pdpSettings: PageConfigSetting[] = [
|
|
119
|
+
{
|
|
120
|
+
key: "pdp.galeria",
|
|
121
|
+
label: "Galería",
|
|
122
|
+
opciones: [
|
|
123
|
+
{ value: "grid", label: "Grid" },
|
|
124
|
+
{ value: "carrusel", label: "Carrusel" },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: "pdp.stickyBar",
|
|
129
|
+
label: "Barra sticky",
|
|
130
|
+
opciones: [
|
|
131
|
+
{ value: "si", label: "Sí" },
|
|
132
|
+
{ value: "no", label: "No" },
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
key: "pdp.compraRapida",
|
|
137
|
+
label: "Compra rápida",
|
|
138
|
+
opciones: [
|
|
139
|
+
{ value: "si", label: "Sí" },
|
|
140
|
+
{ value: "no", label: "No" },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
key: "pdp.mostrarFavoritos",
|
|
145
|
+
label: "Mostrar favoritos",
|
|
146
|
+
opciones: [
|
|
147
|
+
{ value: "si", label: "Sí" },
|
|
148
|
+
{ value: "no", label: "No" },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
key: "pdp.mostrarReseñas",
|
|
153
|
+
label: "Mostrar reseñas",
|
|
154
|
+
opciones: [
|
|
155
|
+
{ value: "si", label: "Sí" },
|
|
156
|
+
{ value: "no", label: "No" },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
key: "pdp.mostrarRelacionados",
|
|
161
|
+
label: "Mostrar relacionados",
|
|
162
|
+
opciones: [
|
|
163
|
+
{ value: "si", label: "Sí" },
|
|
164
|
+
{ value: "no", label: "No" },
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { fileURLToPath } from "node:url"
|
|
3
|
+
import { describe, expect, it } from "vitest"
|
|
4
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
5
|
+
import type { Facet } from "@arroyavecommerce/cms-core"
|
|
6
|
+
import { ControlDeFiltro } from "./filter-controls"
|
|
7
|
+
import { mockCtx } from "../test/mock-ctx"
|
|
8
|
+
|
|
9
|
+
const codigoFuente = readFileSync(fileURLToPath(new URL("./filter-controls.tsx", import.meta.url)), "utf-8")
|
|
10
|
+
|
|
11
|
+
const urlFiltro = (facetId: string, valor: string) => `?${facetId}=${valor}`
|
|
12
|
+
|
|
13
|
+
describe("ControlDeFiltro", () => {
|
|
14
|
+
it("checkbox: renderiza una opcion por opciones con etiqueta+conteo, como Enlace a urlFiltro; marca la seleccionada", () => {
|
|
15
|
+
const facet: Facet = {
|
|
16
|
+
id: "categoria",
|
|
17
|
+
label: "Categoría",
|
|
18
|
+
control: "checkbox",
|
|
19
|
+
source: { type: "tag", tagIds: ["t1", "t2"] },
|
|
20
|
+
order: 1,
|
|
21
|
+
}
|
|
22
|
+
const html = renderToStaticMarkup(
|
|
23
|
+
<ControlDeFiltro
|
|
24
|
+
facet={facet}
|
|
25
|
+
seleccionados={["camisetas"]}
|
|
26
|
+
opciones={[
|
|
27
|
+
{ valor: "camisetas", etiqueta: "Camisetas", conteo: 128 },
|
|
28
|
+
{ valor: "hoodies", etiqueta: "Hoodies", conteo: 42 },
|
|
29
|
+
]}
|
|
30
|
+
urlFiltro={urlFiltro}
|
|
31
|
+
ctx={mockCtx()}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
expect(html).toContain("Camisetas (128)")
|
|
36
|
+
expect(html).toContain("Hoodies (42)")
|
|
37
|
+
expect(html).toContain('href="?categoria=camisetas"')
|
|
38
|
+
expect(html).toContain('href="?categoria=hoodies"')
|
|
39
|
+
// aria-current debe estar en el <a> real (mismo tag que el href), no en el <li>/<span> contenedor.
|
|
40
|
+
expect(html).toMatch(/<a href="\?categoria=camisetas"[^>]*aria-current="true"/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("pill: renderiza chips (Enlace) por opcion; el seleccionado queda resaltado", () => {
|
|
44
|
+
const facet: Facet = {
|
|
45
|
+
id: "talla",
|
|
46
|
+
label: "Talla",
|
|
47
|
+
control: "pill",
|
|
48
|
+
source: { type: "tag", tagIds: ["t1"] },
|
|
49
|
+
order: 1,
|
|
50
|
+
}
|
|
51
|
+
const html = renderToStaticMarkup(
|
|
52
|
+
<ControlDeFiltro
|
|
53
|
+
facet={facet}
|
|
54
|
+
seleccionados={["S"]}
|
|
55
|
+
opciones={[
|
|
56
|
+
{ valor: "S", etiqueta: "S" },
|
|
57
|
+
{ valor: "M", etiqueta: "M" },
|
|
58
|
+
]}
|
|
59
|
+
urlFiltro={urlFiltro}
|
|
60
|
+
ctx={mockCtx()}
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
expect(html).toContain('href="?talla=S"')
|
|
65
|
+
expect(html).toContain('href="?talla=M"')
|
|
66
|
+
// aria-current debe estar en el <a> real (mismo tag que el href), no en el <span> contenedor.
|
|
67
|
+
expect(html).toMatch(/<a href="\?talla=S"[^>]*aria-current="true"/)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('dropdown: renderiza un boton "label ▾"', () => {
|
|
71
|
+
const facet: Facet = {
|
|
72
|
+
id: "color",
|
|
73
|
+
label: "Color",
|
|
74
|
+
control: "dropdown",
|
|
75
|
+
source: { type: "tag", tagIds: ["t1"] },
|
|
76
|
+
order: 1,
|
|
77
|
+
}
|
|
78
|
+
const html = renderToStaticMarkup(
|
|
79
|
+
<ControlDeFiltro
|
|
80
|
+
facet={facet}
|
|
81
|
+
seleccionados={[]}
|
|
82
|
+
opciones={[{ valor: "negro", etiqueta: "Negro" }]}
|
|
83
|
+
urlFiltro={urlFiltro}
|
|
84
|
+
ctx={mockCtx()}
|
|
85
|
+
/>
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
expect(html).toContain("Color")
|
|
89
|
+
expect(html).toContain("▾")
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("range: renderiza dos extremos de precio (min/max) que navegan por urlFiltro", () => {
|
|
93
|
+
const facet: Facet = {
|
|
94
|
+
id: "precio",
|
|
95
|
+
label: "Precio",
|
|
96
|
+
control: "range",
|
|
97
|
+
source: { type: "price" },
|
|
98
|
+
order: 1,
|
|
99
|
+
}
|
|
100
|
+
const html = renderToStaticMarkup(
|
|
101
|
+
<ControlDeFiltro
|
|
102
|
+
facet={facet}
|
|
103
|
+
seleccionados={[]}
|
|
104
|
+
opciones={[
|
|
105
|
+
{ valor: "min", etiqueta: "$ 49.900" },
|
|
106
|
+
{ valor: "max", etiqueta: "$ 189.900" },
|
|
107
|
+
]}
|
|
108
|
+
urlFiltro={urlFiltro}
|
|
109
|
+
ctx={mockCtx()}
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
expect(html).toContain("$ 49.900")
|
|
114
|
+
expect(html).toContain("$ 189.900")
|
|
115
|
+
expect(html).toContain('href="?precio=min"')
|
|
116
|
+
expect(html).toContain('href="?precio=max"')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("faceta invalida (control:range + source:tag) devuelve null", () => {
|
|
120
|
+
const facet: Facet = {
|
|
121
|
+
id: "x",
|
|
122
|
+
label: "X",
|
|
123
|
+
control: "range",
|
|
124
|
+
source: { type: "tag", tagIds: ["x"] },
|
|
125
|
+
order: 1,
|
|
126
|
+
}
|
|
127
|
+
const resultado = ControlDeFiltro({ facet, seleccionados: [], opciones: [], urlFiltro, ctx: mockCtx() })
|
|
128
|
+
|
|
129
|
+
expect(resultado).toBeNull()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("faceta invalida (control:pill + source:price) devuelve null", () => {
|
|
133
|
+
const facet: Facet = {
|
|
134
|
+
id: "y",
|
|
135
|
+
label: "Y",
|
|
136
|
+
control: "pill",
|
|
137
|
+
source: { type: "price" },
|
|
138
|
+
order: 1,
|
|
139
|
+
}
|
|
140
|
+
const resultado = ControlDeFiltro({ facet, seleccionados: [], opciones: [], urlFiltro, ctx: mockCtx() })
|
|
141
|
+
|
|
142
|
+
expect(resultado).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("no emite <img> ni <a> directos: todo pasa por ctx.componentes", () => {
|
|
146
|
+
expect(codigoFuente).not.toMatch(/<img[\s>]/)
|
|
147
|
+
expect(codigoFuente).not.toMatch(/<a[\s>]/)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, type ComponentType } from "react"
|
|
4
|
+
import type { Facet } from "@arroyavecommerce/cms-core"
|
|
5
|
+
import { facetControlEsValido } from "@arroyavecommerce/cms-core"
|
|
6
|
+
import type { RenderContext } from "../context"
|
|
7
|
+
|
|
8
|
+
export type FilterControlProps = {
|
|
9
|
+
facet: Facet
|
|
10
|
+
seleccionados: string[]
|
|
11
|
+
opciones: { valor: string; etiqueta: string; conteo?: number }[]
|
|
12
|
+
urlFiltro: (facetId: string, valor: string) => string
|
|
13
|
+
ctx: RenderContext
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function etiquetaOpcion(opcion: FilterControlProps["opciones"][number]): string {
|
|
17
|
+
return opcion.conteo != null ? `${opcion.etiqueta} (${opcion.conteo})` : opcion.etiqueta
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** <li>+Enlace compartido por CheckboxControl y la lista de DropdownControl: aria-current va en el ancla real. */
|
|
21
|
+
function OpcionEnlace({
|
|
22
|
+
opcion,
|
|
23
|
+
activa,
|
|
24
|
+
href,
|
|
25
|
+
Enlace,
|
|
26
|
+
}: {
|
|
27
|
+
opcion: FilterControlProps["opciones"][number]
|
|
28
|
+
activa: boolean
|
|
29
|
+
href: string
|
|
30
|
+
Enlace: RenderContext["componentes"]["Enlace"]
|
|
31
|
+
}) {
|
|
32
|
+
return (
|
|
33
|
+
<li>
|
|
34
|
+
<Enlace
|
|
35
|
+
href={href}
|
|
36
|
+
aria-current={activa ? "true" : undefined}
|
|
37
|
+
className={`inline-block [text-decoration:none] ${
|
|
38
|
+
activa ? "font-semibold [color:var(--cms-texto)]" : "font-normal [color:var(--cms-texto-suave)]"
|
|
39
|
+
}`}
|
|
40
|
+
>
|
|
41
|
+
{etiquetaOpcion(opcion)}
|
|
42
|
+
</Enlace>
|
|
43
|
+
</li>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Lista vertical de sidebar (mockup PLP l.44-55): "Camisetas (128)" con la activa resaltada. */
|
|
48
|
+
function CheckboxControl({ facet, seleccionados, opciones, urlFiltro, ctx }: FilterControlProps) {
|
|
49
|
+
const { Enlace } = ctx.componentes
|
|
50
|
+
return (
|
|
51
|
+
<div className="flex flex-col gap-2.5">
|
|
52
|
+
<div
|
|
53
|
+
className="text-[11px] font-semibold uppercase tracking-[0.1em] [color:var(--cms-texto)]"
|
|
54
|
+
style={{ fontFamily: "var(--cms-fuente-texto)" }}
|
|
55
|
+
>
|
|
56
|
+
{facet.label}
|
|
57
|
+
</div>
|
|
58
|
+
<ul className="m-0 flex list-none flex-col gap-2 p-0 text-[13px]">
|
|
59
|
+
{opciones.map((opcion) => (
|
|
60
|
+
<OpcionEnlace
|
|
61
|
+
key={opcion.valor}
|
|
62
|
+
opcion={opcion}
|
|
63
|
+
activa={seleccionados.includes(opcion.valor)}
|
|
64
|
+
href={urlFiltro(facet.id, opcion.valor)}
|
|
65
|
+
Enlace={Enlace}
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Chips horizontales (mockup PLP l.113-118): seleccionado resaltado en primario. */
|
|
74
|
+
function PillControl({ facet, seleccionados, opciones, urlFiltro, ctx }: FilterControlProps) {
|
|
75
|
+
const { Enlace } = ctx.componentes
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex flex-wrap gap-2" role="group" aria-label={facet.label}>
|
|
78
|
+
{opciones.map((opcion) => {
|
|
79
|
+
const activa = seleccionados.includes(opcion.valor)
|
|
80
|
+
return (
|
|
81
|
+
<span key={opcion.valor}>
|
|
82
|
+
<Enlace
|
|
83
|
+
href={urlFiltro(facet.id, opcion.valor)}
|
|
84
|
+
aria-current={activa ? "true" : undefined}
|
|
85
|
+
className={`inline-block rounded-full px-4 py-2 text-[12px] font-semibold [text-decoration:none] ${
|
|
86
|
+
activa
|
|
87
|
+
? "[background:var(--cms-primario)] [color:var(--cms-primario-texto)]"
|
|
88
|
+
: "font-normal [border:var(--cms-grosor-borde)_solid_var(--cms-borde)] [color:var(--cms-texto)]"
|
|
89
|
+
}`}
|
|
90
|
+
>
|
|
91
|
+
{etiquetaOpcion(opcion)}
|
|
92
|
+
</Enlace>
|
|
93
|
+
</span>
|
|
94
|
+
)
|
|
95
|
+
})}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Extremos de precio min/max (mockup PLP l.68-75); navegan por urlFiltro. */
|
|
101
|
+
function RangeControl({ facet, opciones, urlFiltro, ctx }: FilterControlProps) {
|
|
102
|
+
const { Enlace } = ctx.componentes
|
|
103
|
+
const [minOpcion, maxOpcion] = opciones
|
|
104
|
+
return (
|
|
105
|
+
<div className="flex flex-col gap-2.5">
|
|
106
|
+
<div
|
|
107
|
+
className="text-[11px] font-semibold uppercase tracking-[0.1em] [color:var(--cms-texto)]"
|
|
108
|
+
style={{ fontFamily: "var(--cms-fuente-texto)" }}
|
|
109
|
+
>
|
|
110
|
+
{facet.label}
|
|
111
|
+
</div>
|
|
112
|
+
<div
|
|
113
|
+
className="flex items-center justify-between text-[11px] font-medium [color:var(--cms-texto-suave)]"
|
|
114
|
+
style={{ fontFamily: "var(--cms-fuente-mono)" }}
|
|
115
|
+
>
|
|
116
|
+
{minOpcion ? (
|
|
117
|
+
<Enlace
|
|
118
|
+
href={urlFiltro(facet.id, minOpcion.valor)}
|
|
119
|
+
className="[color:inherit] [text-decoration:none]"
|
|
120
|
+
>
|
|
121
|
+
{minOpcion.etiqueta}
|
|
122
|
+
</Enlace>
|
|
123
|
+
) : (
|
|
124
|
+
<span />
|
|
125
|
+
)}
|
|
126
|
+
{maxOpcion ? (
|
|
127
|
+
<Enlace
|
|
128
|
+
href={urlFiltro(facet.id, maxOpcion.valor)}
|
|
129
|
+
className="[color:inherit] [text-decoration:none]"
|
|
130
|
+
>
|
|
131
|
+
{maxOpcion.etiqueta}
|
|
132
|
+
</Enlace>
|
|
133
|
+
) : (
|
|
134
|
+
<span />
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Botón "label ▾" (mockup PLP l.114) que abre un panel de opciones vía useState. */
|
|
142
|
+
function DropdownControl({ facet, seleccionados, opciones, urlFiltro, ctx }: FilterControlProps) {
|
|
143
|
+
const [abierto, setAbierto] = useState(false)
|
|
144
|
+
const { Enlace } = ctx.componentes
|
|
145
|
+
return (
|
|
146
|
+
<div className="relative inline-block text-[12px]">
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={() => setAbierto((valor) => !valor)}
|
|
150
|
+
aria-expanded={abierto}
|
|
151
|
+
className="inline-flex items-center gap-1.5 px-4 py-2 font-medium [background:var(--cms-superficie)] [border:var(--cms-grosor-borde)_solid_var(--cms-borde)] [border-radius:var(--cms-radio)] [color:var(--cms-texto)]"
|
|
152
|
+
style={{ fontFamily: "var(--cms-fuente-texto)" }}
|
|
153
|
+
>
|
|
154
|
+
{facet.label} ▾
|
|
155
|
+
</button>
|
|
156
|
+
{abierto ? (
|
|
157
|
+
<ul
|
|
158
|
+
className="absolute left-0 top-full z-10 m-0 mt-1.5 flex min-w-[160px] list-none flex-col gap-1 p-2 text-[12.5px] [background:var(--cms-superficie)] [border:var(--cms-grosor-borde)_solid_var(--cms-borde)] [border-radius:var(--cms-radio)]"
|
|
159
|
+
>
|
|
160
|
+
{opciones.map((opcion) => (
|
|
161
|
+
<OpcionEnlace
|
|
162
|
+
key={opcion.valor}
|
|
163
|
+
opcion={opcion}
|
|
164
|
+
activa={seleccionados.includes(opcion.valor)}
|
|
165
|
+
href={urlFiltro(facet.id, opcion.valor)}
|
|
166
|
+
Enlace={Enlace}
|
|
167
|
+
/>
|
|
168
|
+
))}
|
|
169
|
+
</ul>
|
|
170
|
+
) : null}
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const filterControls: Record<"pill" | "dropdown" | "checkbox" | "range", ComponentType<FilterControlProps>> = {
|
|
176
|
+
pill: PillControl,
|
|
177
|
+
dropdown: DropdownControl,
|
|
178
|
+
checkbox: CheckboxControl,
|
|
179
|
+
range: RangeControl,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Resuelve el widget por `facet.control`; devuelve `null` si la combinación control×fuente no es válida. */
|
|
183
|
+
export function ControlDeFiltro(props: FilterControlProps) {
|
|
184
|
+
if (!facetControlEsValido(props.facet)) return null
|
|
185
|
+
const Widget = filterControls[props.facet.control]
|
|
186
|
+
return <Widget {...props} />
|
|
187
|
+
}
|