@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,52 @@
|
|
|
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 { Relacionados } from "./related"
|
|
6
|
+
import { mockCtx, fixtureDetalle } from "../test/mock-ctx"
|
|
7
|
+
|
|
8
|
+
const codigoFuente = readFileSync(fileURLToPath(new URL("./related.tsx", import.meta.url)), "utf-8")
|
|
9
|
+
|
|
10
|
+
describe("Relacionados", () => {
|
|
11
|
+
it("renderiza una TarjetaProducto por producto (titulo presente) + h3 del titulo (default)", () => {
|
|
12
|
+
const detalle = fixtureDetalle()
|
|
13
|
+
const productos = detalle.relacionados!
|
|
14
|
+
const ctx = mockCtx()
|
|
15
|
+
const html = renderToStaticMarkup(<Relacionados productos={productos} ctx={ctx} />)
|
|
16
|
+
|
|
17
|
+
for (const producto of productos) {
|
|
18
|
+
expect(html).toContain(producto.title)
|
|
19
|
+
}
|
|
20
|
+
expect(html).toContain("Combínalo con")
|
|
21
|
+
const h3s = html.match(/<h3[\s>]/g) ?? []
|
|
22
|
+
expect(h3s).toHaveLength(1)
|
|
23
|
+
expect(html).not.toMatch(/<h1[\s>]/)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("acepta un titulo custom", () => {
|
|
27
|
+
const detalle = fixtureDetalle()
|
|
28
|
+
const productos = detalle.relacionados!
|
|
29
|
+
const ctx = mockCtx()
|
|
30
|
+
const html = renderToStaticMarkup(<Relacionados productos={productos} ctx={ctx} titulo="Te puede interesar" />)
|
|
31
|
+
|
|
32
|
+
expect(html).toContain("Te puede interesar")
|
|
33
|
+
expect(html).not.toContain("Combínalo con")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("grid 2-col mobile -> 4-col desktop", () => {
|
|
37
|
+
const detalle = fixtureDetalle()
|
|
38
|
+
const productos = detalle.relacionados!
|
|
39
|
+
const ctx = mockCtx()
|
|
40
|
+
const html = renderToStaticMarkup(<Relacionados productos={productos} ctx={ctx} />)
|
|
41
|
+
|
|
42
|
+
expect(html).toContain("grid-cols-2")
|
|
43
|
+
expect(html).toContain("md:grid-cols-4")
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe("guard de estandar (cero <img>/<a> directos)", () => {
|
|
48
|
+
it("no emite <img> ni <a> directos", () => {
|
|
49
|
+
expect(codigoFuente).not.toMatch(/<img[\s>]/)
|
|
50
|
+
expect(codigoFuente).not.toMatch(/<a[\s>]/)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { HttpTypes } from "@medusajs/types"
|
|
2
|
+
import type { RenderContext } from "../context"
|
|
3
|
+
import { TarjetaProducto } from "./product-card"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Slider/grid de productos relacionados del PDP (mockup PDP l.168-179): `<h3>`
|
|
7
|
+
* (default "Combínalo con") + grid 2-col mobile → 4-col desktop de
|
|
8
|
+
* `TarjetaProducto` (`card:"minimal"`). Presentacional. Gated por
|
|
9
|
+
* `config.mostrarRelacionados` en `PdpLayout`.
|
|
10
|
+
*/
|
|
11
|
+
export function Relacionados({
|
|
12
|
+
productos,
|
|
13
|
+
ctx,
|
|
14
|
+
titulo = "Combínalo con",
|
|
15
|
+
}: {
|
|
16
|
+
productos: HttpTypes.StoreProduct[]
|
|
17
|
+
ctx: RenderContext
|
|
18
|
+
titulo?: string
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex flex-col gap-4">
|
|
22
|
+
<h3
|
|
23
|
+
className="text-[22px]"
|
|
24
|
+
style={{
|
|
25
|
+
fontFamily: "var(--cms-fuente-titulo)",
|
|
26
|
+
fontWeight: "var(--cms-titulo-peso)" as unknown as number,
|
|
27
|
+
textTransform: "var(--cms-titulo-transform)" as unknown as "none",
|
|
28
|
+
letterSpacing: "var(--cms-titulo-espaciado)",
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
{titulo}
|
|
32
|
+
</h3>
|
|
33
|
+
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 md:gap-5">
|
|
34
|
+
{productos.map((producto) => (
|
|
35
|
+
<TarjetaProducto key={producto.id} product={producto} card="minimal" ctx={ctx} />
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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 { ReviewsSummary } from "./reviews"
|
|
6
|
+
import { mockCtx, fixtureDetalle } from "../test/mock-ctx"
|
|
7
|
+
|
|
8
|
+
const codigoFuente = readFileSync(fileURLToPath(new URL("./reviews.tsx", import.meta.url)), "utf-8")
|
|
9
|
+
|
|
10
|
+
describe("ReviewsSummary", () => {
|
|
11
|
+
it("renderiza el promedio, el total, las barras del histograma y las cards de la muestra; un h2, sin h1", () => {
|
|
12
|
+
const detalle = fixtureDetalle()
|
|
13
|
+
const reseñas = detalle.reseñas!
|
|
14
|
+
const ctx = mockCtx()
|
|
15
|
+
const html = renderToStaticMarkup(<ReviewsSummary reseñas={reseñas} ctx={ctx} />)
|
|
16
|
+
|
|
17
|
+
expect(html).toContain("4.6")
|
|
18
|
+
expect(html).toContain("213")
|
|
19
|
+
for (const fila of reseñas.histograma) {
|
|
20
|
+
expect(html).toContain(`${fila.porcentaje}%`)
|
|
21
|
+
}
|
|
22
|
+
for (const reseña of reseñas.muestra) {
|
|
23
|
+
expect(html).toContain(reseña.autor)
|
|
24
|
+
expect(html).toContain(reseña.texto)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const h2s = html.match(/<h2[\s>]/g) ?? []
|
|
28
|
+
expect(h2s).toHaveLength(1)
|
|
29
|
+
expect(html).not.toMatch(/<h1[\s>]/)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('renderiza "Compra verificada · Talla N" para reseñas verificadas con talla', () => {
|
|
33
|
+
const detalle = fixtureDetalle()
|
|
34
|
+
const reseñas = detalle.reseñas!
|
|
35
|
+
const ctx = mockCtx()
|
|
36
|
+
const html = renderToStaticMarkup(<ReviewsSummary reseñas={reseñas} ctx={ctx} />)
|
|
37
|
+
|
|
38
|
+
expect(html).toContain("Compra verificada · Talla 38")
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('renderiza el enlace "Ver las N reseñas" con el total', () => {
|
|
42
|
+
const detalle = fixtureDetalle()
|
|
43
|
+
const reseñas = detalle.reseñas!
|
|
44
|
+
const ctx = mockCtx()
|
|
45
|
+
const html = renderToStaticMarkup(<ReviewsSummary reseñas={reseñas} ctx={ctx} />)
|
|
46
|
+
|
|
47
|
+
expect(html).toContain(`Ver las ${reseñas.total} reseñas`)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe("guard de estandar (cero <img>/<a> directos)", () => {
|
|
52
|
+
it("no emite <img> ni <a> directos", () => {
|
|
53
|
+
expect(codigoFuente).not.toMatch(/<img[\s>]/)
|
|
54
|
+
expect(codigoFuente).not.toMatch(/<a[\s>]/)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { RenderContext } from "../context"
|
|
2
|
+
|
|
3
|
+
export type Reseñas = NonNullable<NonNullable<NonNullable<RenderContext["catalogo"]>["detalle"]>["reseñas"]>
|
|
4
|
+
|
|
5
|
+
function Estrellas({ estrellas }: { estrellas: number }) {
|
|
6
|
+
const llenas = Math.round(estrellas)
|
|
7
|
+
return (
|
|
8
|
+
<span aria-hidden="true">
|
|
9
|
+
{"★".repeat(Math.max(0, Math.min(5, llenas)))}
|
|
10
|
+
{"☆".repeat(Math.max(0, 5 - llenas))}
|
|
11
|
+
</span>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Barra del histograma (mockup l.149-153): etiqueta "N★" + barra proporcional + porcentaje. */
|
|
16
|
+
function FilaHistograma({ estrellas, porcentaje }: { estrellas: number; porcentaje: number }) {
|
|
17
|
+
return (
|
|
18
|
+
<div className="flex items-center gap-2 text-[10.5px] font-medium" style={{ fontFamily: "var(--cms-fuente-mono)" }}>
|
|
19
|
+
<span className="w-4">{estrellas}★</span>
|
|
20
|
+
<span
|
|
21
|
+
className="h-[5px] flex-1 overflow-hidden rounded-[3px]"
|
|
22
|
+
style={{ background: "var(--cms-borde)" }}
|
|
23
|
+
>
|
|
24
|
+
<span
|
|
25
|
+
className="block h-full"
|
|
26
|
+
style={{ width: `${porcentaje}%`, background: "var(--cms-texto)" }}
|
|
27
|
+
/>
|
|
28
|
+
</span>
|
|
29
|
+
<span>{porcentaje}%</span>
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Card de una reseña individual (mockup l.157-164): estrellas+autor, "Compra verificada · Talla N", texto. */
|
|
35
|
+
function CardReseña({ reseña }: { reseña: Reseñas["muestra"][number] }) {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className="p-4"
|
|
39
|
+
style={{
|
|
40
|
+
background: "var(--cms-superficie)",
|
|
41
|
+
border: "var(--cms-grosor-borde) solid var(--cms-borde)",
|
|
42
|
+
borderRadius: "var(--cms-radio)",
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
<div className="flex justify-between text-[12px]">
|
|
46
|
+
<span className="font-semibold">
|
|
47
|
+
<Estrellas estrellas={reseña.estrellas} /> · {reseña.autor}
|
|
48
|
+
</span>
|
|
49
|
+
<span style={{ color: "var(--cms-texto-suave)" }}>
|
|
50
|
+
{reseña.verificada ? "Compra verificada" : null}
|
|
51
|
+
{reseña.verificada && reseña.talla ? ` · Talla ${reseña.talla}` : null}
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
<p className="mt-2 text-[12.5px] leading-[1.6]" style={{ color: "var(--cms-texto-suave)" }}>
|
|
55
|
+
{reseña.texto}
|
|
56
|
+
</p>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resumen de reseñas del PDP (mockup PDP l.143-166): rating grande + histograma
|
|
63
|
+
* de barras (izq) y cards de la muestra + "Ver las N reseñas" (der). Presentacional
|
|
64
|
+
* (sin backend). Gated por `config.mostrarReseñas` en `PdpLayout`.
|
|
65
|
+
*/
|
|
66
|
+
export function ReviewsSummary({ reseñas, ctx: _ctx }: { reseñas: Reseñas; ctx: RenderContext }) {
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex flex-col gap-4">
|
|
69
|
+
<h2
|
|
70
|
+
className="text-[22px]"
|
|
71
|
+
style={{
|
|
72
|
+
fontFamily: "var(--cms-fuente-titulo)",
|
|
73
|
+
fontWeight: "var(--cms-titulo-peso)" as unknown as number,
|
|
74
|
+
textTransform: "var(--cms-titulo-transform)" as unknown as "none",
|
|
75
|
+
letterSpacing: "var(--cms-titulo-espaciado)",
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
Reseñas
|
|
79
|
+
</h2>
|
|
80
|
+
<div
|
|
81
|
+
className="grid grid-cols-1 gap-9 pb-6 md:grid-cols-[280px_1fr]"
|
|
82
|
+
style={{ borderBottom: "var(--cms-grosor-borde) solid var(--cms-borde)" }}
|
|
83
|
+
>
|
|
84
|
+
<div>
|
|
85
|
+
<div className="text-[44px] font-bold" style={{ fontFamily: "var(--cms-fuente-mono)" }}>
|
|
86
|
+
{reseñas.promedio}
|
|
87
|
+
</div>
|
|
88
|
+
<div className="text-[14px]">
|
|
89
|
+
<Estrellas estrellas={reseñas.promedio} />
|
|
90
|
+
</div>
|
|
91
|
+
<div className="mt-1 text-[12px]" style={{ color: "var(--cms-texto-suave)" }}>
|
|
92
|
+
{reseñas.total} reseñas verificadas
|
|
93
|
+
</div>
|
|
94
|
+
<div className="mt-3.5 flex flex-col gap-1.5">
|
|
95
|
+
{reseñas.histograma.map((fila) => (
|
|
96
|
+
<FilaHistograma key={fila.estrellas} estrellas={fila.estrellas} porcentaje={fila.porcentaje} />
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
<div className="flex flex-col gap-4">
|
|
101
|
+
{reseñas.muestra.map((reseña, i) => (
|
|
102
|
+
<CardReseña key={`${reseña.autor}-${i}`} reseña={reseña} />
|
|
103
|
+
))}
|
|
104
|
+
{/* Stub presentacional: la cascara/backend de reseñas cablea la navegacion (sub-proyecto 5) */}
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
className="self-start text-[12px] underline"
|
|
108
|
+
style={{ background: "transparent", border: "none", color: "var(--cms-texto)", padding: 0 }}
|
|
109
|
+
>
|
|
110
|
+
Ver las {reseñas.total} reseñas
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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 { SkeletonPlp, SkeletonPdp } from "./skeletons"
|
|
6
|
+
import * as barrel from "../index"
|
|
7
|
+
|
|
8
|
+
const codigoFuente = readFileSync(fileURLToPath(new URL("./skeletons.tsx", import.meta.url)), "utf-8")
|
|
9
|
+
|
|
10
|
+
describe("SkeletonPlp", () => {
|
|
11
|
+
it("renderiza status accesible + grilla de tarjetas placeholder", () => {
|
|
12
|
+
const html = renderToStaticMarkup(<SkeletonPlp columnas={4} cantidad={8} />)
|
|
13
|
+
expect(html).toContain('data-skeleton="plp"')
|
|
14
|
+
expect(html).toContain('role="status"')
|
|
15
|
+
expect(html).toContain('aria-busy="true"')
|
|
16
|
+
expect(html).toContain("data-skeleton-grid")
|
|
17
|
+
expect(html).toContain("md:grid-cols-4")
|
|
18
|
+
// 8 tarjetas placeholder → 8 bloques con aspect-ratio de imagen.
|
|
19
|
+
const bloquesImg = html.match(/aspect-ratio:3 \/ 4/g) ?? html.match(/aspect-ratio:3\/4/g) ?? []
|
|
20
|
+
expect(bloquesImg.length).toBe(8)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("columnas:3 aplica md:grid-cols-3", () => {
|
|
24
|
+
const html = renderToStaticMarkup(<SkeletonPlp columnas={3} />)
|
|
25
|
+
expect(html).toContain("md:grid-cols-3")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("shimmer respeta prefers-reduced-motion (animate-pulse + motion-reduce:animate-none)", () => {
|
|
29
|
+
const html = renderToStaticMarkup(<SkeletonPlp />)
|
|
30
|
+
expect(html).toContain("animate-pulse")
|
|
31
|
+
expect(html).toContain("motion-reduce:animate-none")
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe("SkeletonPdp", () => {
|
|
36
|
+
it("renderiza galeria + buy-box placeholder con layout 2-col desktop", () => {
|
|
37
|
+
const html = renderToStaticMarkup(<SkeletonPdp />)
|
|
38
|
+
expect(html).toContain('data-skeleton="pdp"')
|
|
39
|
+
expect(html).toContain('role="status"')
|
|
40
|
+
expect(html).toContain("md:grid-cols-[1.5fr_1fr]")
|
|
41
|
+
expect(html).toContain("animate-pulse")
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe("barrel", () => {
|
|
46
|
+
it("SkeletonPlp y SkeletonPdp se exportan desde el índice del template", () => {
|
|
47
|
+
expect(barrel.SkeletonPlp).toBe(SkeletonPlp)
|
|
48
|
+
expect(barrel.SkeletonPdp).toBe(SkeletonPdp)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe("guard de estandar (server-safe, cero <img>/<a> directos, sin use client)", () => {
|
|
53
|
+
it("no emite <img> ni <a> directos", () => {
|
|
54
|
+
expect(codigoFuente).not.toMatch(/<img[\s>]/)
|
|
55
|
+
expect(codigoFuente).not.toMatch(/<a[\s>]/)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('es server-safe: NO declara "use client" (presentacional, sin hooks)', () => {
|
|
59
|
+
expect(codigoFuente).not.toMatch(/^\s*["']use client["']/)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { CSSProperties } from "react"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Skeletons de carga presentacionales (server-safe: sin hooks, sin `"use
|
|
5
|
+
* client"`) para que el host los use en sus `loading.tsx`. El shimmer sutil se
|
|
6
|
+
* hace con `animate-pulse` de Tailwind, gateado por `motion-reduce:animate-none`
|
|
7
|
+
* ≡ solo anima con `@media (prefers-reduced-motion: no-preference)`. Todos los
|
|
8
|
+
* colores salen de tokens `var(--cms-*, fallback)`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Bloque placeholder base (superficie + radio + pulse respetando reduced-motion). */
|
|
12
|
+
function Bloque({ className = "", style }: { className?: string; style?: CSSProperties }) {
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
aria-hidden="true"
|
|
16
|
+
className={`animate-pulse motion-reduce:animate-none ${className}`}
|
|
17
|
+
style={{ background: "var(--cms-superficie, #eee)", borderRadius: "var(--cms-radio, 8px)", ...style }}
|
|
18
|
+
/>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Tarjeta placeholder: imagen (aspect 3/4) + dos líneas de texto. */
|
|
23
|
+
function CardSkeleton() {
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex flex-col gap-2">
|
|
26
|
+
<Bloque className="w-full" style={{ aspectRatio: "3 / 4" }} />
|
|
27
|
+
<Bloque className="h-3 w-3/4" />
|
|
28
|
+
<Bloque className="h-3 w-2/5" />
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Skeleton del PLP: cabecera (título + toolbar) + grilla de tarjetas
|
|
35
|
+
* placeholder. `columnas` y `cantidad` son ajustables para casar con la config
|
|
36
|
+
* real de la página. `role="status"` + `aria-busy` para lectores de pantalla.
|
|
37
|
+
*/
|
|
38
|
+
export function SkeletonPlp({
|
|
39
|
+
columnas = 4,
|
|
40
|
+
cantidad = 8,
|
|
41
|
+
}: {
|
|
42
|
+
columnas?: 3 | 4
|
|
43
|
+
cantidad?: number
|
|
44
|
+
}) {
|
|
45
|
+
return (
|
|
46
|
+
<section
|
|
47
|
+
data-skeleton="plp"
|
|
48
|
+
role="status"
|
|
49
|
+
aria-busy="true"
|
|
50
|
+
aria-label="Cargando productos"
|
|
51
|
+
className="flex flex-col gap-5 px-4 py-6 md:px-7 md:py-8"
|
|
52
|
+
>
|
|
53
|
+
<span className="sr-only">Cargando productos…</span>
|
|
54
|
+
<div className="flex items-center justify-between gap-4">
|
|
55
|
+
<Bloque className="h-7 w-40 md:h-9 md:w-64" />
|
|
56
|
+
<Bloque className="h-8 w-28" />
|
|
57
|
+
</div>
|
|
58
|
+
<div
|
|
59
|
+
data-skeleton-grid
|
|
60
|
+
className={`grid w-full grid-cols-2 gap-4 md:gap-5 ${
|
|
61
|
+
columnas === 3 ? "md:grid-cols-3" : "md:grid-cols-4"
|
|
62
|
+
}`}
|
|
63
|
+
>
|
|
64
|
+
{Array.from({ length: cantidad }, (_, i) => (
|
|
65
|
+
<CardSkeleton key={i} />
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
</section>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Skeleton del PDP: galería (imagen grande) + buy-box placeholder (eyebrow,
|
|
74
|
+
* título, precio, chips de talla y CTA). Mismo layout 2-col desktop que el PDP
|
|
75
|
+
* real (`md:grid md:grid-cols-[1.5fr_1fr]`).
|
|
76
|
+
*/
|
|
77
|
+
export function SkeletonPdp() {
|
|
78
|
+
return (
|
|
79
|
+
<section
|
|
80
|
+
data-skeleton="pdp"
|
|
81
|
+
role="status"
|
|
82
|
+
aria-busy="true"
|
|
83
|
+
aria-label="Cargando producto"
|
|
84
|
+
className="flex flex-col gap-6 px-4 py-6 md:px-7 md:py-8"
|
|
85
|
+
>
|
|
86
|
+
<span className="sr-only">Cargando producto…</span>
|
|
87
|
+
<div className="flex flex-col gap-6 md:grid md:grid-cols-[1.5fr_1fr] md:items-start md:gap-9">
|
|
88
|
+
<Bloque className="w-full" style={{ aspectRatio: "1200 / 1500" }} />
|
|
89
|
+
<div className="flex flex-col gap-4">
|
|
90
|
+
<Bloque className="h-3 w-24" />
|
|
91
|
+
<Bloque className="h-7 w-3/4" />
|
|
92
|
+
<Bloque className="h-5 w-32" />
|
|
93
|
+
<div className="flex flex-wrap gap-2 pt-2">
|
|
94
|
+
{Array.from({ length: 5 }, (_, i) => (
|
|
95
|
+
<Bloque key={i} className="h-10 w-12" />
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
<Bloque className="mt-2 h-12 w-full" />
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</section>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
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 { AnnouncementBar, anuncioSchema, anuncioDefaults, type Anuncio } from "./announcement-bar"
|
|
6
|
+
import { mockCtx } from "../test/mock-ctx"
|
|
7
|
+
|
|
8
|
+
const mensajes = [
|
|
9
|
+
{ texto: "Envío gratis desde $150.000" },
|
|
10
|
+
{ texto: "Cambios sin costo en 30 días" },
|
|
11
|
+
{ texto: "Nueva colección disponible" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
function anuncio(overrides?: Partial<Anuncio>): Anuncio {
|
|
15
|
+
return { ...anuncioDefaults, mensajes, ...overrides }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const render = (a: Anuncio) => renderToStaticMarkup(<AnnouncementBar anuncio={a} ctx={mockCtx()} />)
|
|
19
|
+
|
|
20
|
+
describe("AnnouncementBar", () => {
|
|
21
|
+
it("los defaults pasan el schema", () => {
|
|
22
|
+
expect(anuncioSchema.safeParse(anuncioDefaults).success).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("activo:false no muestra la barra", () => {
|
|
26
|
+
expect(render(anuncio({ activo: false }))).toBe("")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("sin mensajes no muestra la barra", () => {
|
|
30
|
+
expect(render(anuncio({ mensajes: [] }))).toBe("")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("variantes", () => {
|
|
34
|
+
it("estatica muestra todos los mensajes", () => {
|
|
35
|
+
const html = render(anuncio({ variante: "estatica" }))
|
|
36
|
+
for (const m of mensajes) expect(html).toContain(m.texto)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("rotativa muestra el primer mensaje en el render inicial", () => {
|
|
40
|
+
const html = render(anuncio({ variante: "rotativa" }))
|
|
41
|
+
expect(html).toContain(mensajes[0]!.texto)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("marquesina duplica el track para el loop y respeta prefers-reduced-motion", () => {
|
|
45
|
+
const html = render(anuncio({ variante: "marquesina" }))
|
|
46
|
+
expect(html).toContain("cms-marq-track")
|
|
47
|
+
expect(html).toContain("prefers-reduced-motion")
|
|
48
|
+
// El primer mensaje aparece dos veces (track duplicado).
|
|
49
|
+
const ocurrencias = html.split(mensajes[0]!.texto).length - 1
|
|
50
|
+
expect(ocurrencias).toBe(2)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe("color", () => {
|
|
55
|
+
it("acento usa var(--cms-acento) con fallback", () => {
|
|
56
|
+
expect(render(anuncio({ color: "acento" }))).toContain("var(--cms-acento,#3b2bff)")
|
|
57
|
+
})
|
|
58
|
+
it("degradado usa var(--cms-degradado) con fallback", () => {
|
|
59
|
+
expect(render(anuncio({ color: "degradado" }))).toContain("var(--cms-degradado,")
|
|
60
|
+
})
|
|
61
|
+
it("tinta usa var(--cms-texto) de fondo", () => {
|
|
62
|
+
expect(render(anuncio({ color: "tinta" }))).toContain("var(--cms-texto,#0c0c10)")
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("un mensaje con href se envuelve en ctx.componentes.Enlace", () => {
|
|
67
|
+
const html = render(anuncio({ variante: "estatica", mensajes: [{ texto: "Ver oferta", href: "/store" }] }))
|
|
68
|
+
expect(html).toContain('href="/store"')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe("dismissible", () => {
|
|
72
|
+
it("true muestra el botón de cierre", () => {
|
|
73
|
+
expect(render(anuncio({ dismissible: true }))).toContain('aria-label="Cerrar anuncio"')
|
|
74
|
+
})
|
|
75
|
+
it("false no muestra el botón de cierre", () => {
|
|
76
|
+
expect(render(anuncio({ dismissible: false }))).not.toContain('aria-label="Cerrar anuncio"')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("no emite <img>/<a> directos: todo pasa por ctx.componentes", () => {
|
|
81
|
+
const rutaFuente = fileURLToPath(new URL("./announcement-bar.tsx", import.meta.url))
|
|
82
|
+
const codigoFuente = readFileSync(rutaFuente, "utf-8")
|
|
83
|
+
expect(codigoFuente).not.toMatch(/<img[\s>]/)
|
|
84
|
+
expect(codigoFuente).not.toMatch(/<a[\s>]/)
|
|
85
|
+
})
|
|
86
|
+
})
|