@arroyavecommerce/cms-storefront 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/package.json +41 -0
- package/src/__fixtures__/template-demo.test.tsx +11 -0
- package/src/__fixtures__/template-demo.tsx +56 -0
- package/src/fetchers.ts +2 -0
- package/src/filter-url.test.ts +55 -0
- package/src/filter-url.ts +36 -0
- package/src/filters.test.ts +71 -0
- package/src/filters.ts +70 -0
- package/src/index.ts +9 -0
- package/src/pageconfig.test.ts +14 -0
- package/src/pageconfig.ts +10 -0
- package/src/render.test.tsx +30 -0
- package/src/render.tsx +31 -0
- package/src/smoke.test.ts +8 -0
- package/src/studio.test.ts +50 -0
- package/src/studio.ts +71 -0
- package/src/template.test.tsx +36 -0
- package/src/template.ts +56 -0
- package/src/theme.test.ts +16 -0
- package/src/validate.test.ts +57 -0
- package/src/validate.ts +30 -0
- package/tsconfig.json +4 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@arroyavecommerce/cms-storefront",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Host storefront del CMS de ArroyaveCommerce: contrato defineTemplate y primitivas de render/tema.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./fetchers": "./src/fetchers.ts",
|
|
12
|
+
"./studio": "./src/studio.ts",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"typesVersions": {
|
|
16
|
+
"*": {
|
|
17
|
+
"fetchers": [
|
|
18
|
+
"./src/fetchers.ts"
|
|
19
|
+
],
|
|
20
|
+
"studio": [
|
|
21
|
+
"./src/studio.ts"
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@arroyavecommerce/cms-core": ">=0.1.0",
|
|
27
|
+
"@arroyavecommerce/cms-studio": ">=0.1.0",
|
|
28
|
+
"@puckeditor/core": ">=0.22",
|
|
29
|
+
"react": ">=19",
|
|
30
|
+
"react-dom": ">=19",
|
|
31
|
+
"zod": ">=4.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@arroyavecommerce/cms-core": "0.2.0",
|
|
35
|
+
"@arroyavecommerce/cms-studio": "0.2.0",
|
|
36
|
+
"@puckeditor/core": "^0.22.0",
|
|
37
|
+
"react": "^19.0.0",
|
|
38
|
+
"react-dom": "^19.0.0",
|
|
39
|
+
"zod": "^4.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { cmsPageDataSchema } from "@arroyavecommerce/cms-core"
|
|
3
|
+
import { templateDemo } from "./template-demo"
|
|
4
|
+
|
|
5
|
+
describe("templateDemo (fixture)", () => {
|
|
6
|
+
it("declara los dos bloques y un home por defecto válido", () => {
|
|
7
|
+
expect(Object.keys(templateDemo.blocks)).toEqual(["heroDemo", "avisoDemo"])
|
|
8
|
+
const parsed = cmsPageDataSchema.safeParse(templateDemo.defaultHomePage)
|
|
9
|
+
expect(parsed.success).toBe(true)
|
|
10
|
+
})
|
|
11
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import { defineBlocks, type BlockDefinition } from "@arroyavecommerce/cms-core"
|
|
3
|
+
import { defineTemplate, type Template } from "../template"
|
|
4
|
+
|
|
5
|
+
export type DemoCtx = { destacado: string }
|
|
6
|
+
|
|
7
|
+
const heroDemo: BlockDefinition<{ titulo: string }, DemoCtx> = {
|
|
8
|
+
label: "Hero demo",
|
|
9
|
+
schema: z.object({ titulo: z.string().min(1) }),
|
|
10
|
+
defaults: { titulo: "Bienvenido" },
|
|
11
|
+
Component: ({ props, ctx }) => (
|
|
12
|
+
<section>
|
|
13
|
+
<h1>{props.titulo}</h1>
|
|
14
|
+
<p>{ctx.destacado}</p>
|
|
15
|
+
</section>
|
|
16
|
+
),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const avisoDemo: BlockDefinition<{ texto: string }, DemoCtx> = {
|
|
20
|
+
label: "Aviso demo",
|
|
21
|
+
schema: z.object({ texto: z.string().min(1) }),
|
|
22
|
+
defaults: { texto: "Envíos a todo el país" },
|
|
23
|
+
Component: ({ props }) => <aside>{props.texto}</aside>,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const blocks = defineBlocks<DemoCtx>()({ heroDemo, avisoDemo })
|
|
27
|
+
|
|
28
|
+
export const templateDemo: Template<DemoCtx> = defineTemplate<DemoCtx>()({
|
|
29
|
+
meta: { id: "demo", nombre: "Demo", industria: "test" },
|
|
30
|
+
blocks,
|
|
31
|
+
puckConfig: { components: {} },
|
|
32
|
+
theme: {
|
|
33
|
+
definition: {
|
|
34
|
+
tokens: {
|
|
35
|
+
"color-primario": {
|
|
36
|
+
label: "Primario",
|
|
37
|
+
tipo: "color",
|
|
38
|
+
default: "#111111",
|
|
39
|
+
cssVars: ["--cms-primario"],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
fuentes: {},
|
|
43
|
+
},
|
|
44
|
+
defaults: { "color-primario": "#111111" },
|
|
45
|
+
marca: "Demo",
|
|
46
|
+
},
|
|
47
|
+
defaultHomePage: {
|
|
48
|
+
version: 1,
|
|
49
|
+
blocks: [
|
|
50
|
+
{ id: "b1", type: "heroDemo", props: blocks.heroDemo.defaults },
|
|
51
|
+
{ id: "b2", type: "avisoDemo", props: blocks.avisoDemo.defaults },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
reservados: ["store", "checkout"],
|
|
55
|
+
basePath: "/p",
|
|
56
|
+
})
|
package/src/fetchers.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { facetSeleccionados, construirUrlFiltro } from "./filter-url"
|
|
3
|
+
|
|
4
|
+
describe("facetSeleccionados", () => {
|
|
5
|
+
it("normaliza un array existente", () => {
|
|
6
|
+
expect(facetSeleccionados({ talla: ["S", "M"] }, "talla")).toEqual(["S", "M"])
|
|
7
|
+
})
|
|
8
|
+
it("devuelve [] si la faceta está ausente", () => {
|
|
9
|
+
expect(facetSeleccionados({}, "talla")).toEqual([])
|
|
10
|
+
})
|
|
11
|
+
it("normaliza un string suelto a array de un elemento", () => {
|
|
12
|
+
expect(facetSeleccionados({ c: "x" }, "c")).toEqual(["x"])
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe("construirUrlFiltro", () => {
|
|
17
|
+
it("agrega el valor cuando la faceta está vacía", () => {
|
|
18
|
+
const url = construirUrlFiltro({}, { id: "talla", control: "pill" }, "S")
|
|
19
|
+
expect(url).toContain("talla=S")
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("toggle multi (pill): quita el valor si ya estaba seleccionado", () => {
|
|
23
|
+
const url = construirUrlFiltro({ talla: "S" }, { id: "talla", control: "pill" }, "S")
|
|
24
|
+
expect(url).not.toContain("talla=S")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("toggle multi (checkbox): agrega el valor conservando el existente", () => {
|
|
28
|
+
const url = construirUrlFiltro({ talla: "S" }, { id: "talla", control: "checkbox" }, "M")
|
|
29
|
+
expect(url).toContain("talla=S")
|
|
30
|
+
expect(url).toContain("talla=M")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("single (dropdown): reemplaza el valor existente", () => {
|
|
34
|
+
const url = construirUrlFiltro({ orden: "a" }, { id: "orden", control: "dropdown" }, "b")
|
|
35
|
+
expect(url).toContain("orden=b")
|
|
36
|
+
expect(url).not.toContain("orden=a")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("range: reemplaza el rango existente", () => {
|
|
40
|
+
const url = construirUrlFiltro({ precio: "0-100" }, { id: "precio", control: "range" }, "50-200")
|
|
41
|
+
expect(url).toContain("precio=50-200")
|
|
42
|
+
expect(url).not.toContain("precio=0-100")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("preserva otras keys del query", () => {
|
|
46
|
+
const url = construirUrlFiltro({ color: "negro", talla: "S" }, { id: "talla", control: "pill" }, "M")
|
|
47
|
+
expect(url).toContain("color=negro")
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("al togglear el último valor de una faceta, la key desaparece (no queda vacía)", () => {
|
|
51
|
+
const url = construirUrlFiltro({ talla: "S" }, { id: "talla", control: "pill" }, "S")
|
|
52
|
+
expect(url).not.toMatch(/talla=(&|$)/)
|
|
53
|
+
expect(url).not.toContain("talla=")
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Facet } from "@arroyavecommerce/cms-core"
|
|
2
|
+
|
|
3
|
+
export function facetSeleccionados(query: Record<string, string | string[]>, facetId: string): string[] {
|
|
4
|
+
const v = query[facetId]
|
|
5
|
+
if (v === undefined) return []
|
|
6
|
+
return Array.isArray(v) ? v : [v]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function construirUrlFiltro(
|
|
10
|
+
query: Record<string, string | string[]>,
|
|
11
|
+
facet: Pick<Facet, "id" | "control">,
|
|
12
|
+
valor: string,
|
|
13
|
+
): string {
|
|
14
|
+
const params = new URLSearchParams()
|
|
15
|
+
for (const [key, v] of Object.entries(query)) {
|
|
16
|
+
if (key === facet.id) continue
|
|
17
|
+
for (const item of Array.isArray(v) ? v : [v]) {
|
|
18
|
+
params.append(key, item)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const esMulti = facet.control === "pill" || facet.control === "checkbox"
|
|
23
|
+
if (esMulti) {
|
|
24
|
+
const actuales = facetSeleccionados(query, facet.id)
|
|
25
|
+
const nuevos = actuales.includes(valor) ? actuales.filter((x) => x !== valor) : [...actuales, valor]
|
|
26
|
+
for (const item of nuevos) {
|
|
27
|
+
params.append(facet.id, item)
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
// single: dropdown y range/price reemplazan el valor de la faceta
|
|
31
|
+
params.append(facet.id, valor)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const qs = params.toString()
|
|
35
|
+
return qs ? `?${qs}` : ""
|
|
36
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import type { CmsFiltersConfig } from "@arroyavecommerce/cms-core"
|
|
3
|
+
import { resolveFilters } from "./filters"
|
|
4
|
+
|
|
5
|
+
const config: CmsFiltersConfig = {
|
|
6
|
+
version: 1,
|
|
7
|
+
facets: [
|
|
8
|
+
{ id: "rareza", label: "Rareza", control: "pill", source: { type: "tag", tagIds: ["t1", "t2"] }, order: 1 },
|
|
9
|
+
{ id: "cat", label: "Categoría", control: "dropdown", source: { type: "category", parentId: "pcat" }, order: 2 },
|
|
10
|
+
{ id: "precio", label: "Precio", control: "range", source: { type: "price" }, order: 3 },
|
|
11
|
+
// faceta inválida (price con pill): debe omitirse
|
|
12
|
+
{ id: "malo", label: "Malo", control: "pill", source: { type: "price" }, order: 4 },
|
|
13
|
+
],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("resolveFilters", () => {
|
|
17
|
+
it("mapea tag/category a params de Store API (OR dentro de la faceta)", () => {
|
|
18
|
+
const r = resolveFilters(config, { rareza: ["t1", "t2"], cat: "c9" })
|
|
19
|
+
expect(r.params.tag_id).toEqual(["t1", "t2"])
|
|
20
|
+
expect(r.params.category_id).toEqual(["c9"])
|
|
21
|
+
})
|
|
22
|
+
it("descarta valores de tag que no están en el bucket", () => {
|
|
23
|
+
const r = resolveFilters(config, { rareza: ["t1", "ajeno"] })
|
|
24
|
+
expect(r.params.tag_id).toEqual(["t1"])
|
|
25
|
+
})
|
|
26
|
+
it("parsea el rango de precio y omite la faceta inválida", () => {
|
|
27
|
+
const r = resolveFilters(config, { precio: "10-50", malo: "x" })
|
|
28
|
+
expect(r.price).toEqual({ min: 10, max: 50 })
|
|
29
|
+
// 'malo' (price+pill) se omite: no rompe ni agrega params
|
|
30
|
+
expect(r.params.tag_id).toBeUndefined()
|
|
31
|
+
})
|
|
32
|
+
it("ignora keys sin faceta y no incluye arrays vacíos", () => {
|
|
33
|
+
const r = resolveFilters(config, { inexistente: "z" })
|
|
34
|
+
expect(r.params).toEqual({})
|
|
35
|
+
expect(r.price).toBeUndefined()
|
|
36
|
+
})
|
|
37
|
+
it("mapea source.type collection a collection_id", () => {
|
|
38
|
+
const cfg: CmsFiltersConfig = {
|
|
39
|
+
version: 1,
|
|
40
|
+
facets: [
|
|
41
|
+
{ id: "col", label: "Colección", control: "pill", source: { type: "collection" }, order: 1 },
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
const r = resolveFilters(cfg, { col: ["cx", "cy"] })
|
|
45
|
+
expect(r.params.collection_id).toEqual(["cx", "cy"])
|
|
46
|
+
})
|
|
47
|
+
it("parsea rangos de precio parciales (solo min o solo max)", () => {
|
|
48
|
+
const cfg: CmsFiltersConfig = {
|
|
49
|
+
version: 1,
|
|
50
|
+
facets: [
|
|
51
|
+
{ id: "precio", label: "Precio", control: "range", source: { type: "price" }, order: 1 },
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
const r1 = resolveFilters(cfg, { precio: "-50" })
|
|
55
|
+
expect(r1.price).toEqual({ max: 50 })
|
|
56
|
+
const r2 = resolveFilters(cfg, { precio: "10-" })
|
|
57
|
+
expect(r2.price).toEqual({ min: 10 })
|
|
58
|
+
})
|
|
59
|
+
it("fusiona múltiples facetas tag del mismo source.type y deduplica", () => {
|
|
60
|
+
const cfg: CmsFiltersConfig = {
|
|
61
|
+
version: 1,
|
|
62
|
+
facets: [
|
|
63
|
+
{ id: "a", label: "Tags A", control: "pill", source: { type: "tag", tagIds: ["t1", "t2"] }, order: 1 },
|
|
64
|
+
{ id: "b", label: "Tags B", control: "pill", source: { type: "tag", tagIds: ["t2", "t3"] }, order: 2 },
|
|
65
|
+
],
|
|
66
|
+
}
|
|
67
|
+
const r = resolveFilters(cfg, { a: ["t1", "t2"], b: ["t2", "t3"] })
|
|
68
|
+
expect(r.params.tag_id).toHaveLength(3)
|
|
69
|
+
expect(r.params.tag_id).toEqual(expect.arrayContaining(["t1", "t2", "t3"]))
|
|
70
|
+
})
|
|
71
|
+
})
|
package/src/filters.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { facetControlEsValido, type CmsFiltersConfig } from "@arroyavecommerce/cms-core"
|
|
2
|
+
|
|
3
|
+
export type ResolvedFilters = {
|
|
4
|
+
params: {
|
|
5
|
+
category_id?: string[]
|
|
6
|
+
collection_id?: string[]
|
|
7
|
+
tag_id?: string[]
|
|
8
|
+
}
|
|
9
|
+
price?: { min?: number; max?: number }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function comoArray(v: string | string[] | undefined): string[] {
|
|
13
|
+
if (v === undefined) return []
|
|
14
|
+
return Array.isArray(v) ? v : [v]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function unicos(xs: string[]): string[] {
|
|
18
|
+
return [...new Set(xs)]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parsearPrecio(valor: string): { min?: number; max?: number } | null {
|
|
22
|
+
const m = /^(\d*)-(\d*)$/.exec(valor.trim())
|
|
23
|
+
if (!m) return null
|
|
24
|
+
const out: { min?: number; max?: number } = {}
|
|
25
|
+
if (m[1] !== "") out.min = Number(m[1])
|
|
26
|
+
if (m[2] !== "") out.max = Number(m[2])
|
|
27
|
+
return "min" in out || "max" in out ? out : null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Cruza el config publicado con los parámetros de URL y produce los params de
|
|
32
|
+
* la Store API de Medusa + el rango de precio (aparte, porque la Store API no
|
|
33
|
+
* filtra por precio). Tolerante: facetas que violan la matriz control×fuente
|
|
34
|
+
* se omiten; valores de tag fuera del bucket se descartan; keys sin faceta se
|
|
35
|
+
* ignoran.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveFilters(
|
|
38
|
+
config: CmsFiltersConfig,
|
|
39
|
+
query: Record<string, string | string[]>
|
|
40
|
+
): ResolvedFilters {
|
|
41
|
+
const params: ResolvedFilters["params"] = {}
|
|
42
|
+
let price: ResolvedFilters["price"] | undefined
|
|
43
|
+
|
|
44
|
+
const push = (key: "category_id" | "collection_id" | "tag_id", vals: string[]) => {
|
|
45
|
+
if (!vals.length) return
|
|
46
|
+
params[key] = unicos([...(params[key] ?? []), ...vals])
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const facet of config.facets) {
|
|
50
|
+
if (!facetControlEsValido(facet)) continue
|
|
51
|
+
const seleccion = comoArray(query[facet.id])
|
|
52
|
+
if (facet.source.type === "price") {
|
|
53
|
+
const val = seleccion[0]
|
|
54
|
+
if (val) {
|
|
55
|
+
const r = parsearPrecio(val)
|
|
56
|
+
if (r) price = { ...price, ...r }
|
|
57
|
+
}
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (!seleccion.length) continue
|
|
61
|
+
if (facet.source.type === "category") push("category_id", seleccion)
|
|
62
|
+
else if (facet.source.type === "collection") push("collection_id", seleccion)
|
|
63
|
+
else if (facet.source.type === "tag") {
|
|
64
|
+
const permitidos = new Set(facet.source.tagIds)
|
|
65
|
+
push("tag_id", seleccion.filter((v) => permitidos.has(v)))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return price ? { params, price } : { params }
|
|
70
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { defineTemplate } from "./template"
|
|
2
|
+
export type { Template, TemplateTheme, CatalogRegistry } from "./template"
|
|
3
|
+
export { renderTemplatePage, templateThemeCss } from "./render"
|
|
4
|
+
export { assertTemplateDefaults } from "./validate"
|
|
5
|
+
export { resolveFilters } from "./filters"
|
|
6
|
+
export type { ResolvedFilters } from "./filters"
|
|
7
|
+
export { resolvePageConfig } from "./pageconfig"
|
|
8
|
+
export type { PageConfigSetting } from "@arroyavecommerce/cms-core"
|
|
9
|
+
export { facetSeleccionados, construirUrlFiltro } from "./filter-url"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { resolvePageConfig } from "./pageconfig"
|
|
3
|
+
|
|
4
|
+
const cfg = { version: 1 as const, selections: { "plp.filtros": "sidebar" } }
|
|
5
|
+
|
|
6
|
+
describe("resolvePageConfig", () => {
|
|
7
|
+
it("devuelve la selección si existe", () => {
|
|
8
|
+
expect(resolvePageConfig(cfg, "plp.filtros", "pills")).toBe("sidebar")
|
|
9
|
+
})
|
|
10
|
+
it("devuelve el fallback si falta o config es null", () => {
|
|
11
|
+
expect(resolvePageConfig(cfg, "pdp.galeria", "grid")).toBe("grid")
|
|
12
|
+
expect(resolvePageConfig(null, "plp.filtros", "pills")).toBe("pills")
|
|
13
|
+
})
|
|
14
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CmsPageConfig } from "@arroyavecommerce/cms-core"
|
|
2
|
+
|
|
3
|
+
export function resolvePageConfig(
|
|
4
|
+
config: CmsPageConfig | null,
|
|
5
|
+
key: string,
|
|
6
|
+
fallback: string
|
|
7
|
+
): string {
|
|
8
|
+
const v = config?.selections?.[key]
|
|
9
|
+
return typeof v === "string" && v.length > 0 ? v : fallback
|
|
10
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
3
|
+
import { templateDemo } from "./__fixtures__/template-demo"
|
|
4
|
+
import { renderTemplatePage } from "./render"
|
|
5
|
+
|
|
6
|
+
describe("renderTemplatePage", () => {
|
|
7
|
+
it("renderiza los bloques del home con el ctx dado", () => {
|
|
8
|
+
const html = renderToStaticMarkup(
|
|
9
|
+
renderTemplatePage({
|
|
10
|
+
template: templateDemo,
|
|
11
|
+
data: templateDemo.defaultHomePage,
|
|
12
|
+
ctx: { destacado: "Nuevo drop" },
|
|
13
|
+
})
|
|
14
|
+
)
|
|
15
|
+
expect(html).toContain("Bienvenido")
|
|
16
|
+
expect(html).toContain("Nuevo drop")
|
|
17
|
+
expect(html).toContain("Envíos a todo el país")
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("omite bloques de tipo desconocido sin romper", () => {
|
|
21
|
+
const html = renderToStaticMarkup(
|
|
22
|
+
renderTemplatePage({
|
|
23
|
+
template: templateDemo,
|
|
24
|
+
data: { version: 1, blocks: [{ id: "x", type: "inexistente", props: {} }] },
|
|
25
|
+
ctx: { destacado: "" },
|
|
26
|
+
})
|
|
27
|
+
)
|
|
28
|
+
expect(html).not.toContain("inexistente")
|
|
29
|
+
})
|
|
30
|
+
})
|
package/src/render.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { CmsRender, type CmsPageData } from "@arroyavecommerce/cms-core"
|
|
2
|
+
import { themeToCss } from "@arroyavecommerce/cms-core/theme"
|
|
3
|
+
import type { Template } from "./template"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Render genérico de una página CMS con los bloques del template activo.
|
|
7
|
+
* Tolerante por herencia de `CmsRender`: bloque desconocido o props
|
|
8
|
+
* inválidas se omiten con warning; la página nunca rompe.
|
|
9
|
+
*/
|
|
10
|
+
export function renderTemplatePage<Ctx>({
|
|
11
|
+
template,
|
|
12
|
+
data,
|
|
13
|
+
ctx,
|
|
14
|
+
}: {
|
|
15
|
+
template: Template<Ctx>
|
|
16
|
+
data: CmsPageData
|
|
17
|
+
ctx: Ctx
|
|
18
|
+
}) {
|
|
19
|
+
return <CmsRender data={data} blocks={template.blocks} ctx={ctx} />
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Tokens publicados → declaraciones CSS para :root, usando la declaración de
|
|
24
|
+
* tema del template. Silencia warnings (delega en la tolerancia de themeToCss).
|
|
25
|
+
*/
|
|
26
|
+
export function templateThemeCss<Ctx>(
|
|
27
|
+
template: Template<Ctx>,
|
|
28
|
+
tokens: Record<string, string>
|
|
29
|
+
): string {
|
|
30
|
+
return themeToCss(tokens, template.theme.definition, () => {})
|
|
31
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { createCmsStudio } from "./studio"
|
|
3
|
+
import { templateDemo } from "./__fixtures__/template-demo"
|
|
4
|
+
|
|
5
|
+
// Las factories del studio importan estos módulos de Next a nivel de módulo.
|
|
6
|
+
vi.mock("next/headers", () => ({ cookies: vi.fn() }))
|
|
7
|
+
vi.mock("next/navigation", () => ({
|
|
8
|
+
redirect: vi.fn(() => {
|
|
9
|
+
throw new Error("NEXT_REDIRECT")
|
|
10
|
+
}),
|
|
11
|
+
}))
|
|
12
|
+
vi.mock("next/cache", () => ({ revalidateTag: vi.fn() }))
|
|
13
|
+
|
|
14
|
+
const env = {
|
|
15
|
+
backendUrl: "http://back",
|
|
16
|
+
publishableKey: "pk_test",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.unstubAllGlobals()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe("createCmsStudio", () => {
|
|
24
|
+
it("expone los cinco grupos de actions con sus funciones", () => {
|
|
25
|
+
const studio = createCmsStudio(templateDemo, env)
|
|
26
|
+
expect(typeof studio.auth.loginAction).toBe("function")
|
|
27
|
+
expect(typeof studio.auth.getStudioToken).toBe("function")
|
|
28
|
+
expect(typeof studio.auth.getStudioUser).toBe("function")
|
|
29
|
+
expect(typeof studio.cms.listPagesAction).toBe("function")
|
|
30
|
+
expect(typeof studio.cms.createHomePageAction).toBe("function")
|
|
31
|
+
expect(typeof studio.theme.getThemeDraftAction).toBe("function")
|
|
32
|
+
expect(typeof studio.zones.createZoneAction).toBe("function")
|
|
33
|
+
expect(typeof studio.pages.createPageAction).toBe("function")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("cablea env.backendUrl en las actions (verificado vía verifyAdminToken)", async () => {
|
|
37
|
+
const fetchFn = vi.fn().mockResolvedValue({
|
|
38
|
+
ok: true,
|
|
39
|
+
json: async () => ({ user: { id: "u1", email: "a@b.c" } }),
|
|
40
|
+
})
|
|
41
|
+
vi.stubGlobal("fetch", fetchFn)
|
|
42
|
+
const studio = createCmsStudio(templateDemo, env)
|
|
43
|
+
const user = await studio.auth.verifyAdminToken("tok")
|
|
44
|
+
expect(user).toEqual({ id: "u1", email: "a@b.c" })
|
|
45
|
+
expect(fetchFn).toHaveBeenCalledWith(
|
|
46
|
+
"http://back/admin/users/me",
|
|
47
|
+
expect.objectContaining({ headers: { authorization: "Bearer tok" } })
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
})
|
package/src/studio.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
makeAuthActions,
|
|
3
|
+
makeCmsActions,
|
|
4
|
+
makeThemeActions,
|
|
5
|
+
makeZoneActions,
|
|
6
|
+
makePageManagerActions,
|
|
7
|
+
} from "@arroyavecommerce/cms-studio/actions"
|
|
8
|
+
import type { Template } from "./template"
|
|
9
|
+
|
|
10
|
+
/** Valores de entorno/infra de la instancia (no del template). */
|
|
11
|
+
export type StudioEnv = {
|
|
12
|
+
backendUrl: string
|
|
13
|
+
publishableKey: string
|
|
14
|
+
cookieName?: string
|
|
15
|
+
studioPath?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Instancia todas las server-action factories del studio desde un Template y
|
|
20
|
+
* el entorno, con un único `getStudioToken` compartido. Reemplaza el cuerpo
|
|
21
|
+
* repetido de los archivos de wiring de la tienda.
|
|
22
|
+
*
|
|
23
|
+
* SEGURIDAD: este módulo NO lleva "use server". Devuelve
|
|
24
|
+
* `auth.getStudioToken`/`auth.getStudioUser` (helpers server-only); la tienda
|
|
25
|
+
* debe extraerlos SOLO en un módulo `server-only` (p. ej. su session.ts) y
|
|
26
|
+
* NUNCA re-exportarlos desde un módulo `"use server"`.
|
|
27
|
+
*
|
|
28
|
+
* Uso típico en la tienda (session.ts, server-only):
|
|
29
|
+
* const studio = createCmsStudio(miTemplate, { backendUrl, publishableKey })
|
|
30
|
+
* export const getStudioToken = studio.auth.getStudioToken
|
|
31
|
+
* y en cada shim "use server":
|
|
32
|
+
* export const { listPagesAction, saveDraftAction } = studio.cms
|
|
33
|
+
*/
|
|
34
|
+
export function createCmsStudio<Ctx>(template: Template<Ctx>, env: StudioEnv) {
|
|
35
|
+
const auth = makeAuthActions({
|
|
36
|
+
backendUrl: env.backendUrl,
|
|
37
|
+
cookieName: env.cookieName,
|
|
38
|
+
studioPath: env.studioPath,
|
|
39
|
+
})
|
|
40
|
+
const getStudioToken = auth.getStudioToken
|
|
41
|
+
|
|
42
|
+
const cms = makeCmsActions({
|
|
43
|
+
backendUrl: env.backendUrl,
|
|
44
|
+
getStudioToken,
|
|
45
|
+
defaultHomePage: template.defaultHomePage,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const theme = makeThemeActions({
|
|
49
|
+
backendUrl: env.backendUrl,
|
|
50
|
+
getStudioToken,
|
|
51
|
+
theme: {
|
|
52
|
+
defaults: template.theme.defaults,
|
|
53
|
+
tokens: template.theme.definition.tokens,
|
|
54
|
+
fuentes: template.theme.definition.fuentes,
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const zones = makeZoneActions({
|
|
59
|
+
backendUrl: env.backendUrl,
|
|
60
|
+
publishableKey: env.publishableKey,
|
|
61
|
+
getStudioToken,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const pages = makePageManagerActions({
|
|
65
|
+
backendUrl: env.backendUrl,
|
|
66
|
+
getStudioToken,
|
|
67
|
+
reservados: template.reservados,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return { auth, cms, theme, zones, pages }
|
|
71
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { defineBlocks, type BlockDefinition } from "@arroyavecommerce/cms-core"
|
|
4
|
+
import { defineTemplate, type Template } from "./template"
|
|
5
|
+
|
|
6
|
+
type Ctx = { saludo: string }
|
|
7
|
+
|
|
8
|
+
const holaBlock: BlockDefinition<{ texto: string }, Ctx> = {
|
|
9
|
+
label: "Hola",
|
|
10
|
+
schema: z.object({ texto: z.string().min(1) }),
|
|
11
|
+
defaults: { texto: "Hola" },
|
|
12
|
+
Component: ({ props }) => <p>{props.texto}</p>,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("defineTemplate", () => {
|
|
16
|
+
it("devuelve el mismo objeto (identidad) y preserva su forma", () => {
|
|
17
|
+
const blocks = defineBlocks<Ctx>()({ hola: holaBlock })
|
|
18
|
+
const input = {
|
|
19
|
+
meta: { id: "demo", nombre: "Demo", industria: "test" },
|
|
20
|
+
blocks,
|
|
21
|
+
puckConfig: { components: {} },
|
|
22
|
+
theme: { definition: { tokens: {}, fuentes: {} }, defaults: {} },
|
|
23
|
+
defaultHomePage: { version: 1, blocks: [] },
|
|
24
|
+
reservados: ["store"],
|
|
25
|
+
basePath: "/p",
|
|
26
|
+
} satisfies Template<Ctx>
|
|
27
|
+
const template = defineTemplate<Ctx>()(input)
|
|
28
|
+
// identidad: devuelve exactamente el mismo objeto (por referencia)
|
|
29
|
+
expect(template).toBe(input)
|
|
30
|
+
expect(template.meta.id).toBe("demo")
|
|
31
|
+
expect(template.blocks.hola.label).toBe("Hola")
|
|
32
|
+
// el tipo se preserva: acceso tipado a una clave concreta del registro
|
|
33
|
+
const _t: Template<Ctx> = template
|
|
34
|
+
expect(_t.reservados).toEqual(["store"])
|
|
35
|
+
})
|
|
36
|
+
})
|
package/src/template.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ComponentType } from "react"
|
|
2
|
+
import type { Config } from "@puckeditor/core"
|
|
3
|
+
import type {
|
|
4
|
+
BlockRegistry,
|
|
5
|
+
ChromeRegistry,
|
|
6
|
+
CmsPageConfig,
|
|
7
|
+
CmsPageData,
|
|
8
|
+
PageConfigSetting,
|
|
9
|
+
} from "@arroyavecommerce/cms-core"
|
|
10
|
+
import type { ThemeDefinition } from "@arroyavecommerce/cms-core/theme"
|
|
11
|
+
|
|
12
|
+
/** Contrato de tema de un template: declaración + defaults + texto de muestra. */
|
|
13
|
+
export type TemplateTheme = {
|
|
14
|
+
definition: ThemeDefinition
|
|
15
|
+
defaults: Record<string, string>
|
|
16
|
+
marca?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Contrato del catálogo (PLP/PDP) de un template: los componentes que
|
|
21
|
+
* renderizan listado/detalle (ya resolviendo su propia config internamente)
|
|
22
|
+
* más el descriptor de settings de página (`pageConfigSettings`) que el
|
|
23
|
+
* editor de config de página usa para construir su UI.
|
|
24
|
+
*/
|
|
25
|
+
export type CatalogRegistry<Ctx> = {
|
|
26
|
+
plp: ComponentType<{ ctx: Ctx; pageConfig: CmsPageConfig | null }>
|
|
27
|
+
pdp: ComponentType<{ ctx: Ctx; pageConfig: CmsPageConfig | null }>
|
|
28
|
+
pageConfigSettings: { plp: PageConfigSetting[]; pdp: PageConfigSetting[] }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Un template-paquete auto-contenido. Agrupa todo lo que hoy cada tienda
|
|
33
|
+
* cablea a mano, para que el host lo monte con solo registrarlo.
|
|
34
|
+
* Ctx es el contexto de datos de industria (lo compone el template desde
|
|
35
|
+
* las primitivas del host — ver sub-proyecto 1b).
|
|
36
|
+
*/
|
|
37
|
+
export type Template<Ctx> = {
|
|
38
|
+
meta: { id: string; nombre: string; industria: string }
|
|
39
|
+
blocks: BlockRegistry<Ctx>
|
|
40
|
+
puckConfig: Config
|
|
41
|
+
theme: TemplateTheme
|
|
42
|
+
chrome?: ChromeRegistry<Ctx>
|
|
43
|
+
defaultHomePage: CmsPageData
|
|
44
|
+
reservados: string[]
|
|
45
|
+
basePath: string
|
|
46
|
+
catalogo?: CatalogRegistry<Ctx>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Helper de identidad *curried*: fija Ctx y deja que TypeScript infiera la
|
|
51
|
+
* forma exacta del template (mismo truco que `defineBlocks`).
|
|
52
|
+
* `defineTemplate<MiCtx>()({ ... })`.
|
|
53
|
+
*/
|
|
54
|
+
export function defineTemplate<Ctx>() {
|
|
55
|
+
return <T extends Template<Ctx>>(template: T): T => template
|
|
56
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { templateDemo } from "./__fixtures__/template-demo"
|
|
3
|
+
import { templateThemeCss } from "./render"
|
|
4
|
+
|
|
5
|
+
describe("templateThemeCss", () => {
|
|
6
|
+
it("emite la variable CSS del token publicado", () => {
|
|
7
|
+
const css = templateThemeCss(templateDemo, { "color-primario": "#ff0000" })
|
|
8
|
+
expect(css).toContain("--cms-primario: #ff0000")
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it("omite tokens desconocidos sin romper (tolerante)", () => {
|
|
12
|
+
const css = templateThemeCss(templateDemo, { "token-inexistente": "#fff" })
|
|
13
|
+
expect(css).toBe("")
|
|
14
|
+
expect(css).not.toContain("token-inexistente")
|
|
15
|
+
})
|
|
16
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import {
|
|
4
|
+
defineBlocks,
|
|
5
|
+
defineChrome,
|
|
6
|
+
type BlockDefinition,
|
|
7
|
+
type ChromeVariantDef,
|
|
8
|
+
} from "@arroyavecommerce/cms-core"
|
|
9
|
+
import { type Template } from "./template"
|
|
10
|
+
import { templateDemo } from "./__fixtures__/template-demo"
|
|
11
|
+
import { assertTemplateDefaults } from "./validate"
|
|
12
|
+
|
|
13
|
+
describe("assertTemplateDefaults", () => {
|
|
14
|
+
it("no reporta problemas cuando todos los defaults pasan su schema", () => {
|
|
15
|
+
expect(assertTemplateDefaults(templateDemo)).toEqual([])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("reporta el type del bloque cuyo default no pasa su schema", () => {
|
|
19
|
+
const malo: BlockDefinition<{ n: number }, unknown> = {
|
|
20
|
+
label: "Malo",
|
|
21
|
+
schema: z.object({ n: z.number() }),
|
|
22
|
+
// @ts-expect-error default inválido a propósito
|
|
23
|
+
defaults: { n: "no-es-numero" },
|
|
24
|
+
Component: () => null,
|
|
25
|
+
}
|
|
26
|
+
// @ts-expect-error spreading templateDemo with different context type
|
|
27
|
+
const t: Template<unknown> = {
|
|
28
|
+
...templateDemo,
|
|
29
|
+
blocks: defineBlocks<unknown>()({ malo }),
|
|
30
|
+
}
|
|
31
|
+
expect(assertTemplateDefaults(t)).toEqual(["malo"])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("reporta chrome:header:<key> y chrome:footer:<key> cuando sus defaults no pasan su schema", () => {
|
|
35
|
+
const headerMalo: ChromeVariantDef<{ n: number }, unknown> = {
|
|
36
|
+
label: "Header malo",
|
|
37
|
+
schema: z.object({ n: z.number() }),
|
|
38
|
+
// @ts-expect-error default inválido a propósito
|
|
39
|
+
defaults: { n: "no-es-numero" },
|
|
40
|
+
Component: () => null,
|
|
41
|
+
}
|
|
42
|
+
const footerMalo: ChromeVariantDef<{ n: number }, unknown> = {
|
|
43
|
+
label: "Footer malo",
|
|
44
|
+
schema: z.object({ n: z.number() }),
|
|
45
|
+
// @ts-expect-error default inválido a propósito
|
|
46
|
+
defaults: { n: "no-es-numero" },
|
|
47
|
+
Component: () => null,
|
|
48
|
+
}
|
|
49
|
+
const chrome = defineChrome<unknown>()({
|
|
50
|
+
headers: { malo: headerMalo },
|
|
51
|
+
footers: { malo: footerMalo },
|
|
52
|
+
})
|
|
53
|
+
// @ts-expect-error spreading templateDemo with different context type
|
|
54
|
+
const t: Template<unknown> = { ...templateDemo, chrome }
|
|
55
|
+
expect(assertTemplateDefaults(t)).toEqual(["chrome:header:malo", "chrome:footer:malo"])
|
|
56
|
+
})
|
|
57
|
+
})
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Template } from "./template"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verifica el contrato de un template: cada bloque (y, si existe, cada
|
|
5
|
+
* variante de chrome) debe tener `defaults` que pasen su propio `schema`.
|
|
6
|
+
* Devuelve las claves que fallan (vacío = sano) — `type` para bloques,
|
|
7
|
+
* `chrome:header:<key>`/`chrome:footer:<key>` para chrome. Útil como test
|
|
8
|
+
* de contrato de cada template-paquete.
|
|
9
|
+
*/
|
|
10
|
+
export function assertTemplateDefaults<Ctx>(template: Template<Ctx>): string[] {
|
|
11
|
+
const fallidos: string[] = []
|
|
12
|
+
for (const [type, def] of Object.entries(template.blocks)) {
|
|
13
|
+
if (!def.schema.safeParse(def.defaults).success) {
|
|
14
|
+
fallidos.push(type)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (template.chrome) {
|
|
18
|
+
for (const [key, def] of Object.entries(template.chrome.headers)) {
|
|
19
|
+
if (!def.schema.safeParse(def.defaults).success) {
|
|
20
|
+
fallidos.push(`chrome:header:${key}`)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
for (const [key, def] of Object.entries(template.chrome.footers)) {
|
|
24
|
+
if (!def.schema.safeParse(def.defaults).success) {
|
|
25
|
+
fallidos.push(`chrome:footer:${key}`)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return fallidos
|
|
30
|
+
}
|
package/tsconfig.json
ADDED