@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.
Files changed (99) hide show
  1. package/docs/design/README.md +22 -0
  2. package/docs/design/kinetic-street-concept.html +558 -0
  3. package/package.json +45 -0
  4. package/src/blocks/_shared.tsx +174 -0
  5. package/src/blocks/carrusel.tsx +109 -0
  6. package/src/blocks/category-tiles.test.tsx +59 -0
  7. package/src/blocks/category-tiles.tsx +70 -0
  8. package/src/blocks/editorial-banner.test.tsx +96 -0
  9. package/src/blocks/editorial-banner.tsx +205 -0
  10. package/src/blocks/faq.test.tsx +68 -0
  11. package/src/blocks/faq.tsx +122 -0
  12. package/src/blocks/featured-collection.test.tsx +80 -0
  13. package/src/blocks/featured-collection.tsx +97 -0
  14. package/src/blocks/features.test.tsx +71 -0
  15. package/src/blocks/features.tsx +159 -0
  16. package/src/blocks/hero.test.tsx +75 -0
  17. package/src/blocks/hero.tsx +290 -0
  18. package/src/blocks/index.tsx +35 -0
  19. package/src/blocks/lookbook.test.tsx +80 -0
  20. package/src/blocks/lookbook.tsx +117 -0
  21. package/src/blocks/marquee.test.tsx +54 -0
  22. package/src/blocks/marquee.tsx +114 -0
  23. package/src/blocks/newsletter.test.tsx +60 -0
  24. package/src/blocks/newsletter.tsx +105 -0
  25. package/src/blocks/product-grid.test.tsx +160 -0
  26. package/src/blocks/product-grid.tsx +76 -0
  27. package/src/blocks/promo-countdown.tsx +75 -0
  28. package/src/blocks/promo.test.tsx +71 -0
  29. package/src/blocks/promo.tsx +120 -0
  30. package/src/blocks/rich-text.test.tsx +67 -0
  31. package/src/blocks/rich-text.tsx +75 -0
  32. package/src/blocks/spacer.test.tsx +54 -0
  33. package/src/blocks/spacer.tsx +56 -0
  34. package/src/blocks/testimonials.test.tsx +74 -0
  35. package/src/blocks/testimonials.tsx +104 -0
  36. package/src/blocks/video.test.tsx +159 -0
  37. package/src/blocks/video.tsx +226 -0
  38. package/src/catalog/buy-box.test.tsx +225 -0
  39. package/src/catalog/buy-box.tsx +353 -0
  40. package/src/catalog/config.test.ts +115 -0
  41. package/src/catalog/config.ts +167 -0
  42. package/src/catalog/filter-controls.test.tsx +149 -0
  43. package/src/catalog/filter-controls.tsx +187 -0
  44. package/src/catalog/gallery.test.tsx +201 -0
  45. package/src/catalog/gallery.tsx +212 -0
  46. package/src/catalog/lightbox.test.tsx +95 -0
  47. package/src/catalog/lightbox.tsx +184 -0
  48. package/src/catalog/pdp.test.tsx +309 -0
  49. package/src/catalog/pdp.tsx +354 -0
  50. package/src/catalog/plp-arreglos.test.tsx +235 -0
  51. package/src/catalog/plp-inline.tsx +54 -0
  52. package/src/catalog/plp-overlay.tsx +129 -0
  53. package/src/catalog/plp-parts.test.tsx +146 -0
  54. package/src/catalog/plp-parts.tsx +292 -0
  55. package/src/catalog/plp-pills.tsx +44 -0
  56. package/src/catalog/plp-sidebar.tsx +47 -0
  57. package/src/catalog/plp.test.tsx +319 -0
  58. package/src/catalog/plp.tsx +259 -0
  59. package/src/catalog/product-card.test.tsx +221 -0
  60. package/src/catalog/product-card.tsx +411 -0
  61. package/src/catalog/registry.tsx +16 -0
  62. package/src/catalog/related.test.tsx +52 -0
  63. package/src/catalog/related.tsx +40 -0
  64. package/src/catalog/reviews.test.tsx +56 -0
  65. package/src/catalog/reviews.tsx +116 -0
  66. package/src/catalog/skeletons.test.tsx +61 -0
  67. package/src/catalog/skeletons.tsx +103 -0
  68. package/src/chrome/announcement-bar.test.tsx +86 -0
  69. package/src/chrome/announcement-bar.tsx +173 -0
  70. package/src/chrome/drawer-movil.test.tsx +113 -0
  71. package/src/chrome/drawer-movil.tsx +342 -0
  72. package/src/chrome/footer.test.tsx +133 -0
  73. package/src/chrome/footer.tsx +603 -0
  74. package/src/chrome/header.test.tsx +165 -0
  75. package/src/chrome/header.tsx +592 -0
  76. package/src/chrome/index.ts +26 -0
  77. package/src/chrome/mega-menu.test.tsx +161 -0
  78. package/src/chrome/mega-menu.tsx +317 -0
  79. package/src/chrome/mini-cart.test.tsx +111 -0
  80. package/src/chrome/mini-cart.tsx +320 -0
  81. package/src/content/home.ts +19 -0
  82. package/src/content/seed.test.ts +33 -0
  83. package/src/content/seed.ts +28 -0
  84. package/src/context.ts +104 -0
  85. package/src/icons/icons.test.tsx +118 -0
  86. package/src/icons/index.ts +136 -0
  87. package/src/icons/payment-icons.tsx +241 -0
  88. package/src/icons/social-icons.tsx +108 -0
  89. package/src/icons/svg-base.tsx +68 -0
  90. package/src/index.ts +49 -0
  91. package/src/puck-config.tsx +404 -0
  92. package/src/smoke.test.ts +8 -0
  93. package/src/template.test.tsx +88 -0
  94. package/src/test/mock-ctx.test.tsx +77 -0
  95. package/src/test/mock-ctx.tsx +236 -0
  96. package/src/theme-defaults.test.ts +9 -0
  97. package/src/theme.test.ts +82 -0
  98. package/src/theme.ts +195 -0
  99. package/tsconfig.json +4 -0
@@ -0,0 +1,292 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import type { RenderContext } from "../context"
5
+
6
+ type ListadoPlp = NonNullable<NonNullable<RenderContext["catalogo"]>["listado"]>
7
+
8
+ /** <nav aria-label="Ruta"> con enlaces via ctx.componentes.Enlace; el ultimo item queda como texto plano. */
9
+ export function Breadcrumb({
10
+ items,
11
+ ctx,
12
+ }: {
13
+ items: { texto: string; href: string }[]
14
+ ctx: RenderContext
15
+ }) {
16
+ const { Enlace } = ctx.componentes
17
+ return (
18
+ <nav
19
+ aria-label="Ruta"
20
+ className="flex flex-wrap items-center gap-1.5 text-[11px] font-medium"
21
+ style={{ fontFamily: "var(--cms-fuente-mono)", color: "var(--cms-texto-suave)" }}
22
+ >
23
+ {items.map((item, index) => {
24
+ const esUltimo = index === items.length - 1
25
+ return (
26
+ <span key={item.href} className="flex items-center gap-1.5">
27
+ {esUltimo ? (
28
+ <span style={{ color: "var(--cms-texto)" }}>{item.texto}</span>
29
+ ) : (
30
+ <Enlace href={item.href} className="[color:inherit] [text-decoration:none]">
31
+ {item.texto}
32
+ </Enlace>
33
+ )}
34
+ {esUltimo ? null : <span aria-hidden="true">/</span>}
35
+ </span>
36
+ )
37
+ })}
38
+ </nav>
39
+ )
40
+ }
41
+
42
+ /** Opciones de orden fijas del template (mockup PLP l.37: "Ordenar: Relevancia ▾"). */
43
+ const OPCIONES_ORDEN: { valor: string; etiqueta: string }[] = [
44
+ { valor: "relevancia", etiqueta: "Relevancia" },
45
+ { valor: "novedades", etiqueta: "Novedades" },
46
+ { valor: "precio-asc", etiqueta: "Precio: menor a mayor" },
47
+ { valor: "precio-desc", etiqueta: "Precio: mayor a menor" },
48
+ ]
49
+
50
+ function etiquetaOrden(valor: string): string {
51
+ return OPCIONES_ORDEN.find((opcion) => opcion.valor === valor)?.etiqueta ?? valor
52
+ }
53
+
54
+ /** Boton "Ordenar: <actual> ▾" que abre un panel de opciones (client) que navega por listado.urlOrden. */
55
+ function DropdownOrden({ listado, ctx }: { listado: ListadoPlp; ctx: RenderContext }) {
56
+ const [abierto, setAbierto] = useState(false)
57
+ const { Enlace } = ctx.componentes
58
+ return (
59
+ <div className="relative inline-block text-[12px]">
60
+ <button
61
+ type="button"
62
+ onClick={() => setAbierto((valor) => !valor)}
63
+ aria-expanded={abierto}
64
+ className="inline-flex items-center gap-1.5 px-3.5 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)]"
65
+ style={{ fontFamily: "var(--cms-fuente-texto)" }}
66
+ >
67
+ Ordenar: {etiquetaOrden(listado.orden)} ▾
68
+ </button>
69
+ {abierto ? (
70
+ <ul className="absolute right-0 top-full z-10 m-0 mt-1.5 flex min-w-[190px] 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)]">
71
+ {OPCIONES_ORDEN.map((opcion) => {
72
+ const activa = opcion.valor === listado.orden
73
+ return (
74
+ <li key={opcion.valor}>
75
+ <Enlace
76
+ href={listado.urlOrden(opcion.valor)}
77
+ aria-current={activa ? "true" : undefined}
78
+ className={`inline-block w-full [text-decoration:none] ${
79
+ activa ? "font-semibold [color:var(--cms-texto)]" : "font-normal [color:var(--cms-texto-suave)]"
80
+ }`}
81
+ >
82
+ {opcion.etiqueta}
83
+ </Enlace>
84
+ </li>
85
+ )
86
+ })}
87
+ </ul>
88
+ ) : null}
89
+ </div>
90
+ )
91
+ }
92
+
93
+ /**
94
+ * Toggle de vista ▦/☰: indicador presentacional. La FUENTE DE VERDAD del layout
95
+ * es `config.vista` (resuelto server-side y aplicado por `FrameProductos`); este
96
+ * toggle solo refleja ese valor inicial (`vistaActual`) y da feedback local. No
97
+ * re-renderiza la grilla ni cambia la URL.
98
+ */
99
+ function ToggleVista({ vistaActual = "grilla" }: { vistaActual?: "grilla" | "lista" }) {
100
+ const [vista, setVista] = useState<"grilla" | "lista">(vistaActual)
101
+ return (
102
+ <div
103
+ className="flex items-center gap-0.5 px-2 py-1.5"
104
+ style={{ border: "var(--cms-grosor-borde) solid var(--cms-borde)", borderRadius: "var(--cms-radio)" }}
105
+ >
106
+ <button
107
+ type="button"
108
+ onClick={() => setVista("grilla")}
109
+ aria-pressed={vista === "grilla"}
110
+ aria-label="Vista en cuadricula"
111
+ className="px-1 text-[13px]"
112
+ style={{ fontWeight: vista === "grilla" ? 700 : 400, color: vista === "grilla" ? "var(--cms-texto)" : "var(--cms-texto-suave)" }}
113
+ >
114
+
115
+ </button>
116
+ <button
117
+ type="button"
118
+ onClick={() => setVista("lista")}
119
+ aria-pressed={vista === "lista"}
120
+ aria-label="Vista en lista"
121
+ className="px-1 text-[13px]"
122
+ style={{ fontWeight: vista === "lista" ? 700 : 400, color: vista === "lista" ? "var(--cms-texto)" : "var(--cms-texto-suave)" }}
123
+ >
124
+
125
+ </button>
126
+ </div>
127
+ )
128
+ }
129
+
130
+ /** Contador de productos + "Ordenar ▾" + toggle de vista (mockup PLP l.35-39). */
131
+ export function ToolbarPlp({
132
+ listado,
133
+ ctx,
134
+ mostrarContador = true,
135
+ vista = "grilla",
136
+ }: {
137
+ listado: ListadoPlp
138
+ ctx: RenderContext
139
+ mostrarContador?: boolean
140
+ vista?: "grilla" | "lista"
141
+ }) {
142
+ return (
143
+ <div className="flex items-center justify-end gap-3.5">
144
+ {mostrarContador ? (
145
+ <span className="text-[12px]" style={{ color: "var(--cms-texto-suave)" }}>
146
+ {listado.total} productos
147
+ </span>
148
+ ) : null}
149
+ <DropdownOrden listado={listado} ctx={ctx} />
150
+ <ToggleVista vistaActual={vista} />
151
+ </div>
152
+ )
153
+ }
154
+
155
+ function etiquetaFiltro(listado: ListadoPlp, facetId: string, valor: string): string {
156
+ return listado.opcionesFaceta[facetId]?.find((opcion) => opcion.valor === valor)?.etiqueta ?? valor
157
+ }
158
+
159
+ /**
160
+ * Un chip por cada (facetId, valor) en listado.seleccion, con "×" que navega a
161
+ * urlFiltro(facetId, valor) (togglea = quita) + "Limpiar todo". `null` si no hay seleccion.
162
+ *
163
+ * "Limpiar todo": en 4d-i (aislamiento) navega a "?" (ruta sin query). La cascara real
164
+ * (sub-proyecto 5) es quien conoce la URL canonica de la pagina y debe reemplazar este
165
+ * href por la ruta sin ningun parametro de faceta.
166
+ */
167
+ export function PillsActivas({ listado, ctx }: { listado: ListadoPlp; ctx: RenderContext }) {
168
+ const entradas = Object.entries(listado.seleccion).flatMap(([facetId, valores]) =>
169
+ valores.map((valor) => ({ facetId, valor }))
170
+ )
171
+ if (entradas.length === 0) return null
172
+
173
+ const { Enlace } = ctx.componentes
174
+ return (
175
+ <ul role="list" className="m-0 flex flex-wrap items-center gap-2 p-0">
176
+ {entradas.map(({ facetId, valor }) => {
177
+ const etiqueta = etiquetaFiltro(listado, facetId, valor)
178
+ return (
179
+ <li key={`${facetId}:${valor}`}>
180
+ <Enlace
181
+ href={listado.urlFiltro(facetId, valor)}
182
+ className="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[11.5px] [background:var(--cms-superficie)] [border:var(--cms-grosor-borde)_solid_var(--cms-borde)] [color:var(--cms-texto)] [text-decoration:none]"
183
+ >
184
+ <span aria-hidden="true">{etiqueta}</span>
185
+ <b aria-hidden="true">×</b>
186
+ <span className="sr-only">Quitar filtro {etiqueta}</span>
187
+ </Enlace>
188
+ </li>
189
+ )
190
+ })}
191
+ <li>
192
+ <Enlace
193
+ href="?"
194
+ className="inline-block px-1.5 py-1.5 text-[11.5px] underline [color:var(--cms-texto-suave)] [text-decoration:underline]"
195
+ >
196
+ Limpiar todo
197
+ </Enlace>
198
+ </li>
199
+ </ul>
200
+ )
201
+ }
202
+
203
+ /** Construye la secuencia de paginas a mostrar, con "…" si totalPaginas > 7. */
204
+ function rangoPaginas(pagina: number, totalPaginas: number): (number | "…")[] {
205
+ if (totalPaginas <= 7) {
206
+ return Array.from({ length: totalPaginas }, (_, indice) => indice + 1)
207
+ }
208
+ const candidatas = new Set<number>([1, totalPaginas, pagina - 1, pagina, pagina + 1])
209
+ const ordenadas = [...candidatas].filter((p) => p >= 1 && p <= totalPaginas).sort((a, b) => a - b)
210
+
211
+ const resultado: (number | "…")[] = []
212
+ let anterior = 0
213
+ for (const p of ordenadas) {
214
+ if (anterior !== 0 && p - anterior > 1) resultado.push("…")
215
+ resultado.push(p)
216
+ anterior = p
217
+ }
218
+ return resultado
219
+ }
220
+
221
+ /** <nav aria-label="Paginación"> numerada con hrefPara + aria-current="page" en la actual; elipsis si >7. */
222
+ function PaginacionNumerada({ listado, ctx }: { listado: ListadoPlp; ctx: RenderContext }) {
223
+ const { Enlace } = ctx.componentes
224
+ const { pagina, totalPaginas, hrefPara } = listado.paginacion
225
+ const paginas = rangoPaginas(pagina, totalPaginas)
226
+
227
+ return (
228
+ <nav
229
+ aria-label="Paginación"
230
+ className="flex items-center justify-center gap-1.5 text-[12px] font-medium"
231
+ style={{ fontFamily: "var(--cms-fuente-mono)" }}
232
+ >
233
+ {paginas.map((p, indice) =>
234
+ p === "…" ? (
235
+ <span
236
+ key={`elipsis-${indice}`}
237
+ aria-hidden="true"
238
+ className="flex h-[34px] w-[34px] items-center justify-center"
239
+ style={{ color: "var(--cms-texto-suave)" }}
240
+ >
241
+
242
+ </span>
243
+ ) : (
244
+ <Enlace
245
+ key={p}
246
+ href={hrefPara(p)}
247
+ aria-current={p === pagina ? "page" : undefined}
248
+ className={`flex h-[34px] w-[34px] items-center justify-center [text-decoration:none] ${
249
+ p === pagina
250
+ ? "[background:var(--cms-primario)] [color:var(--cms-primario-texto)]"
251
+ : "[border:var(--cms-grosor-borde)_solid_var(--cms-borde)] [color:var(--cms-texto)]"
252
+ }`}
253
+ >
254
+ {p}
255
+ </Enlace>
256
+ )
257
+ )}
258
+ </nav>
259
+ )
260
+ }
261
+
262
+ /** Boton "Cargar más" presentacional (stub en aislamiento; la cascara conecta la carga real). */
263
+ function BotonCargarMas() {
264
+ return (
265
+ <button
266
+ type="button"
267
+ className="mx-auto block px-6 py-2.5 text-[11.5px] font-semibold uppercase tracking-[0.08em]"
268
+ style={{
269
+ background: "var(--cms-primario)",
270
+ color: "var(--cms-primario-texto)",
271
+ borderRadius: "var(--cms-radio)",
272
+ fontFamily: "var(--cms-fuente-texto)",
273
+ }}
274
+ >
275
+ Cargar más
276
+ </button>
277
+ )
278
+ }
279
+
280
+ /** modo "numerada" (default): nav numerada por hrefPara. modo "load-more": boton client presentacional. */
281
+ export function Paginacion({
282
+ listado,
283
+ ctx,
284
+ modo = "numerada",
285
+ }: {
286
+ listado: ListadoPlp
287
+ ctx: RenderContext
288
+ modo?: "numerada" | "load-more"
289
+ }) {
290
+ if (modo === "load-more") return <BotonCargarMas />
291
+ return <PaginacionNumerada listado={listado} ctx={ctx} />
292
+ }
@@ -0,0 +1,44 @@
1
+ import type { ReactNode } from "react"
2
+ import type { RenderContext } from "../context"
3
+ import { ControlDeFiltro } from "./filter-controls"
4
+ import { facetsValidas } from "./plp"
5
+
6
+ type ListadoPlp = NonNullable<NonNullable<RenderContext["catalogo"]>["listado"]>
7
+
8
+ /**
9
+ * Arreglo desktop "pills" (mockup PLP l.107-132): barra horizontal de
10
+ * chips/dropdowns (uno por faceta válida, via ControlDeFiltro) sobre
11
+ * `children` (la grilla, provista por `PlpLayout`) a todo el ancho. Solo la
12
+ * barra de chips es desktop-only (`hidden md:block`): el filtrado mobile va
13
+ * por el bottom-sheet. `children` NO queda dentro de ningún contenedor `hidden`.
14
+ */
15
+ export function ArregloPills({
16
+ listado,
17
+ ctx,
18
+ children,
19
+ }: {
20
+ listado: ListadoPlp
21
+ ctx: RenderContext
22
+ children: ReactNode
23
+ }) {
24
+ return (
25
+ <div data-arreglo="pills" className="flex flex-col gap-4">
26
+ <div
27
+ className="hidden flex-wrap items-center gap-2 py-3 [border-top:var(--cms-grosor-borde)_solid_var(--cms-borde)] [border-bottom:var(--cms-grosor-borde)_solid_var(--cms-borde)] md:flex"
28
+ >
29
+ {facetsValidas(listado).map((facet) => (
30
+ <div key={facet.id} data-facet={facet.id}>
31
+ <ControlDeFiltro
32
+ facet={facet}
33
+ opciones={listado.opcionesFaceta[facet.id] ?? []}
34
+ seleccionados={listado.seleccion[facet.id] ?? []}
35
+ urlFiltro={listado.urlFiltro}
36
+ ctx={ctx}
37
+ />
38
+ </div>
39
+ ))}
40
+ </div>
41
+ {children}
42
+ </div>
43
+ )
44
+ }
@@ -0,0 +1,47 @@
1
+ import type { ReactNode } from "react"
2
+ import type { RenderContext } from "../context"
3
+ import { ControlDeFiltro } from "./filter-controls"
4
+ import { facetsValidas } from "./plp"
5
+
6
+ type ListadoPlp = NonNullable<NonNullable<RenderContext["catalogo"]>["listado"]>
7
+
8
+ /**
9
+ * Arreglo desktop "sidebar" (mockup PLP l.41-105): aside 240px a la izquierda
10
+ * con una sección por faceta válida + `children` (la grilla, provista por
11
+ * `PlpLayout`) a la derecha. Solo el `<aside>` es desktop-only
12
+ * (`hidden md:flex`): el filtrado mobile va por el bottom-sheet. `children`
13
+ * NO queda dentro de ningún contenedor `hidden`, así la grilla se muestra
14
+ * full-width en mobile.
15
+ */
16
+ export function ArregloSidebar({
17
+ listado,
18
+ ctx,
19
+ children,
20
+ }: {
21
+ listado: ListadoPlp
22
+ ctx: RenderContext
23
+ children: ReactNode
24
+ }) {
25
+ return (
26
+ <div data-arreglo="sidebar" className="flex flex-col gap-7 md:grid md:grid-cols-[240px_1fr]">
27
+ <aside className="hidden flex-col gap-5 text-[13px] md:flex">
28
+ {facetsValidas(listado).map((facet, indice) => (
29
+ <div
30
+ key={facet.id}
31
+ data-facet={facet.id}
32
+ className={indice === 0 ? "" : "[border-top:var(--cms-grosor-borde)_solid_var(--cms-borde)] pt-4"}
33
+ >
34
+ <ControlDeFiltro
35
+ facet={facet}
36
+ opciones={listado.opcionesFaceta[facet.id] ?? []}
37
+ seleccionados={listado.seleccion[facet.id] ?? []}
38
+ urlFiltro={listado.urlFiltro}
39
+ ctx={ctx}
40
+ />
41
+ </div>
42
+ ))}
43
+ </aside>
44
+ {children}
45
+ </div>
46
+ )
47
+ }