create-email-renderer 0.1.1 → 0.3.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/README.md +106 -2
- package/SKILL.md +217 -0
- package/dist/build.d.ts +52 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +169 -0
- package/dist/build.js.map +1 -0
- package/dist/default-blocks.d.ts.map +1 -1
- package/dist/default-blocks.js +377 -5
- package/dist/default-blocks.js.map +1 -1
- package/dist/html-render.d.ts +4 -2
- package/dist/html-render.d.ts.map +1 -1
- package/dist/html-render.js +758 -57
- package/dist/html-render.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/normalize.d.ts +10 -2
- package/dist/normalize.d.ts.map +1 -1
- package/dist/normalize.js +166 -38
- package/dist/normalize.js.map +1 -1
- package/dist/records.d.ts +13 -0
- package/dist/records.d.ts.map +1 -0
- package/dist/records.js +93 -0
- package/dist/records.js.map +1 -0
- package/dist/registry.d.ts +35 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +31 -0
- package/dist/registry.js.map +1 -0
- package/dist/richtext.d.ts +6 -0
- package/dist/richtext.d.ts.map +1 -1
- package/dist/richtext.js +13 -0
- package/dist/richtext.js.map +1 -1
- package/dist/server.d.ts +4 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/tracking.d.ts +12 -0
- package/dist/tracking.d.ts.map +1 -0
- package/dist/tracking.js +78 -0
- package/dist/tracking.js.map +1 -0
- package/dist/types.d.ts +407 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +159 -0
- package/dist/types.js.map +1 -1
- package/docs/BLOCKS.md +530 -0
- package/docs/MCP.md +189 -0
- package/llms.txt +17 -0
- package/package.json +17 -4
package/dist/html-render.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// Serializador puro JSON -> HTML email-safe. Es el equivalente sin React de
|
|
2
2
|
// EmailTemplate (blocks.tsx): mismas estructuras (tablas + estilos inline),
|
|
3
3
|
// mismas variables, mismo richtext. Debe mantenerse visualmente sincronizado.
|
|
4
|
-
import { escapeHtml, renderRichText } from "./richtext.js";
|
|
5
|
-
import {
|
|
4
|
+
import { escapeHtml, isSafeFileUrl, renderRichText } from "./richtext.js";
|
|
5
|
+
import { FILE_KIND_LABELS, fileKind, formatBytes } from "./types.js";
|
|
6
|
+
import { DEFAULT_BLOCK_LIBRARY, DEFAULT_PALETTE, DEFAULT_SETTINGS, } from "./default-blocks.js";
|
|
7
|
+
import { getBlockRenderHtml, registerBlock } from "./registry.js";
|
|
8
|
+
import { applyTracking } from "./tracking.js";
|
|
6
9
|
import { resolveVariables, SAMPLE_CONTEXT } from "./variables.js";
|
|
7
10
|
const alignMap = {
|
|
8
11
|
left: "left",
|
|
@@ -38,13 +41,58 @@ const styleToString = (style) => Object.entries(style)
|
|
|
38
41
|
})
|
|
39
42
|
.join(";");
|
|
40
43
|
const blockPadding = (props) => {
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
+
const layout = props;
|
|
45
|
+
const py = "paddingY" in props ? layout.paddingY : undefined;
|
|
46
|
+
const px = "paddingX" in props ? layout.paddingX : undefined;
|
|
47
|
+
const top = layout.paddingTop;
|
|
48
|
+
const right = layout.paddingRight;
|
|
49
|
+
const bottom = layout.paddingBottom;
|
|
50
|
+
const left = layout.paddingLeft;
|
|
51
|
+
// Sin overrides por lado emitimos el shorthand (paridad con versiones previas).
|
|
52
|
+
if (top === undefined && right === undefined && bottom === undefined && left === undefined) {
|
|
53
|
+
return { padding: `${py ?? 0}px ${px ?? 0}px` };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
paddingTop: top ?? py ?? 0,
|
|
57
|
+
paddingRight: right ?? px ?? 0,
|
|
58
|
+
paddingBottom: bottom ?? py ?? 0,
|
|
59
|
+
paddingLeft: left ?? px ?? 0,
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
/** Padding por lado + márgenes externos del bloque. */
|
|
63
|
+
const blockSpacing = (props) => {
|
|
64
|
+
const layout = props;
|
|
65
|
+
return {
|
|
66
|
+
...blockPadding(props),
|
|
67
|
+
...(layout.marginTop !== undefined ? { marginTop: layout.marginTop } : {}),
|
|
68
|
+
...(layout.marginBottom !== undefined
|
|
69
|
+
? { marginBottom: layout.marginBottom }
|
|
70
|
+
: {}),
|
|
71
|
+
};
|
|
44
72
|
};
|
|
45
73
|
/** Tabla full-width con una celda: equivalente email-safe de <Section>. */
|
|
46
|
-
const section = (style, content) =>
|
|
74
|
+
const section = (style, content) => {
|
|
75
|
+
// Los márgenes no aplican a un <td>; se mueven a la tabla contenedora.
|
|
76
|
+
const cell = {};
|
|
77
|
+
const outer = {};
|
|
78
|
+
for (const [k, v] of Object.entries(style)) {
|
|
79
|
+
if (k.startsWith("margin"))
|
|
80
|
+
outer[k] = v;
|
|
81
|
+
else
|
|
82
|
+
cell[k] = v;
|
|
83
|
+
}
|
|
84
|
+
const outerStyle = styleToString(outer);
|
|
85
|
+
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"${outerStyle ? ` style="${outerStyle}"` : ""}><tbody><tr><td style="${styleToString(cell)}">${content}</td></tr></tbody></table>`;
|
|
86
|
+
};
|
|
47
87
|
const text = (content, style) => `<p style="${styleToString(style)}">${content}</p>`;
|
|
88
|
+
/**
|
|
89
|
+
* Enlace si hay `href`; si no, el mismo contenido como texto (`<span>`).
|
|
90
|
+
* Evita emitir `<a href="">`, que recargaría el correo al pulsarlo y se
|
|
91
|
+
* anunciaría como enlace. Un CTA sin destino se sigue viendo igual.
|
|
92
|
+
*/
|
|
93
|
+
const linkOrSpan = (href, style, inner, attributes = ' target="_blank"') => href
|
|
94
|
+
? `<a href="${escapeHtml(href)}"${attributes} style="${style}">${inner}</a>`
|
|
95
|
+
: `<span style="${style}">${inner}</span>`;
|
|
48
96
|
const renderHeader = (props, context, palette) => {
|
|
49
97
|
const logo = props.logoUrl
|
|
50
98
|
? `<img src="${escapeHtml(resolveVariables(props.logoUrl, context))}" alt="${escapeHtml(props.brandName)}" width="140" style="display:inline-block;border:0;outline:none;" />`
|
|
@@ -55,7 +103,7 @@ const renderHeader = (props, context, palette) => {
|
|
|
55
103
|
return section({
|
|
56
104
|
backgroundColor: props.backgroundColor || palette.DARK,
|
|
57
105
|
textAlign: alignMap[props.align || "center"],
|
|
58
|
-
...
|
|
106
|
+
...blockSpacing(props),
|
|
59
107
|
}, logo + tagline);
|
|
60
108
|
};
|
|
61
109
|
const renderHero = (props, context, palette) => {
|
|
@@ -68,7 +116,7 @@ const renderHero = (props, context, palette) => {
|
|
|
68
116
|
: "";
|
|
69
117
|
return section({
|
|
70
118
|
textAlign: alignMap[props.align || "center"],
|
|
71
|
-
...
|
|
119
|
+
...blockSpacing(props),
|
|
72
120
|
...(props.backgroundColor
|
|
73
121
|
? { backgroundColor: props.backgroundColor }
|
|
74
122
|
: {}),
|
|
@@ -76,36 +124,77 @@ const renderHero = (props, context, palette) => {
|
|
|
76
124
|
};
|
|
77
125
|
const renderHeading = (props, context, palette) => section({
|
|
78
126
|
textAlign: alignMap[props.align || "left"],
|
|
79
|
-
...
|
|
127
|
+
...blockSpacing(props),
|
|
80
128
|
...(props.backgroundColor
|
|
81
129
|
? { backgroundColor: props.backgroundColor }
|
|
82
130
|
: {}),
|
|
83
131
|
}, `<div style="${styleToString({ color: props.color || palette.DARK, fontSize: props.size ?? 22, lineHeight: 1.3, margin: 0, fontWeight: 700 })}">${renderRichText(resolveVariables(props.text, context))}</div>`);
|
|
84
132
|
const renderText = (props, context, palette) => section({
|
|
85
133
|
textAlign: alignMap[props.align || "left"],
|
|
86
|
-
...
|
|
134
|
+
...blockSpacing(props),
|
|
87
135
|
...(props.backgroundColor
|
|
88
136
|
? { backgroundColor: props.backgroundColor }
|
|
89
137
|
: {}),
|
|
90
138
|
}, `<div style="${styleToString({ color: props.color || palette.INK, fontSize: props.size || 15, lineHeight: 1.6, margin: 0 })}">${renderRichText(resolveVariables(props.text, context))}</div>`);
|
|
139
|
+
/** Badge circular con el número de la fila (24px, como react.email). */
|
|
140
|
+
const listBadge = (label, accent) => `<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tbody><tr><td width="24" height="24" align="center" valign="middle" style="${styleToString({ width: 24, height: 24, borderRadius: 9999, backgroundColor: accent, color: "#ffffff", fontSize: 12, fontWeight: 600, lineHeight: 1, textAlign: "center" })}">${escapeHtml(label)}</td></tr></tbody></table>`;
|
|
141
|
+
/** Fila de `numbered` / `with-image`: badge + título + descripción (+ imagen/enlace). */
|
|
142
|
+
const renderListEntry = (entry, index, context, palette, opts) => {
|
|
143
|
+
const badge = listBadge(entry.number && entry.number.trim() !== ""
|
|
144
|
+
? entry.number
|
|
145
|
+
: String(index + 1), opts.accent);
|
|
146
|
+
const title = `<div style="${styleToString({ color: palette.DARK, fontSize: 18, fontWeight: 700, lineHeight: 1.4, margin: "0 0 4px" })}">${renderRichText(resolveVariables(entry.title, context))}</div>`;
|
|
147
|
+
const description = entry.description
|
|
148
|
+
? `<div style="${styleToString({ color: palette.INK, fontSize: 13, lineHeight: 1.5, margin: 0 })}">${renderRichText(resolveVariables(entry.description, context))}</div>`
|
|
149
|
+
: "";
|
|
150
|
+
const link = entry.href
|
|
151
|
+
? `<a href="${escapeHtml(resolveVariables(entry.href, context))}" target="_blank" style="${styleToString({ color: opts.accent, fontSize: 14, fontWeight: 600, textDecoration: "none", display: "block", marginTop: 12 })}">${escapeHtml(entry.linkLabel || "Aprender más →")}</a>`
|
|
152
|
+
: "";
|
|
153
|
+
const table = (cells) => `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr>${cells}</tr></tbody></table>`;
|
|
154
|
+
if (!opts.withImage) {
|
|
155
|
+
// Badge en su propia celda a la izquierda del texto.
|
|
156
|
+
return table(`<td width="24" valign="top" style="width:24px;padding-right:18px;">${badge}</td><td valign="top">${title}${description}${link}</td>`);
|
|
157
|
+
}
|
|
158
|
+
// `with-image` va sin número: imagen a la izquierda y texto a la derecha.
|
|
159
|
+
const image = entry.image
|
|
160
|
+
? `<img src="${escapeHtml(resolveVariables(entry.image, context))}" alt="${escapeHtml(entry.title)}" width="100%" height="${opts.imageHeight}" style="${styleToString({ width: "100%", maxWidth: "100%", display: "block", border: 0, borderRadius: opts.radius, height: opts.imageHeight, objectFit: "cover", objectPosition: "center" })}" />`
|
|
161
|
+
: text("(Imagen)", { color: palette.CREAM_DIM, fontSize: 12, margin: 0 });
|
|
162
|
+
return table(`<td width="40%" valign="top" style="width:40%;padding-right:24px;">${image}</td><td width="60%" valign="top" style="width:60%;padding-right:24px;">${title}${description}${link}</td>`);
|
|
163
|
+
};
|
|
91
164
|
const renderList = (props, context, palette) => {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
165
|
+
const accent = props.accentColor || palette.GOLD;
|
|
166
|
+
let content;
|
|
167
|
+
if (props.variant === "numbered" || props.variant === "with-image") {
|
|
168
|
+
const gap = props.gap ?? 24;
|
|
169
|
+
content = (props.entries || [])
|
|
170
|
+
.map((entry, i) => `<div style="${styleToString({ marginBottom: i === (props.entries || []).length - 1 ? 0 : gap })}">${renderListEntry(entry, i, context, palette, {
|
|
171
|
+
accent,
|
|
172
|
+
radius: props.radius ?? 4,
|
|
173
|
+
imageHeight: props.imageHeight ?? 168,
|
|
174
|
+
withImage: props.variant === "with-image",
|
|
175
|
+
})}</div>`)
|
|
176
|
+
.join("");
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
const gap = props.gap ?? 8;
|
|
180
|
+
content = props.items
|
|
181
|
+
.map((item) => `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom:${gap}px;"><tbody><tr><td width="24" valign="top">${text(escapeHtml(props.icon || "✓"), { color: palette.GOLD, fontWeight: 700, margin: 0, fontSize: 14 })}</td><td valign="top"><div style="${styleToString({ color: palette.INK, fontSize: 14, lineHeight: 1.5, margin: 0 })}">${renderRichText(resolveVariables(item, context))}</div></td></tr></tbody></table>`)
|
|
182
|
+
.join("");
|
|
183
|
+
}
|
|
95
184
|
return section({
|
|
96
|
-
...
|
|
185
|
+
...blockSpacing(props),
|
|
97
186
|
...(props.backgroundColor
|
|
98
187
|
? { backgroundColor: props.backgroundColor }
|
|
99
188
|
: {}),
|
|
100
|
-
},
|
|
189
|
+
}, content);
|
|
101
190
|
};
|
|
102
191
|
const renderButton = (props, context, palette) => section({
|
|
103
192
|
textAlign: alignMap[props.align || "center"],
|
|
104
|
-
...
|
|
193
|
+
...blockSpacing(props),
|
|
105
194
|
...(props.blockBackgroundColor
|
|
106
195
|
? { backgroundColor: props.blockBackgroundColor }
|
|
107
196
|
: {}),
|
|
108
|
-
},
|
|
197
|
+
}, linkOrSpan(resolveVariables(props.href, context), styleToString({ backgroundColor: props.backgroundColor || palette.GOLD, color: props.color || palette.DARK, fontWeight: 700, padding: "14px 28px", borderRadius: 999, fontSize: 14, textTransform: "uppercase", letterSpacing: "0.5px", display: "inline-block", textDecoration: "none" }), escapeHtml(resolveVariables(props.label, context))));
|
|
109
198
|
const renderImage = (props, context, palette) => {
|
|
110
199
|
let content;
|
|
111
200
|
if (props.src) {
|
|
@@ -123,7 +212,7 @@ const renderImage = (props, context, palette) => {
|
|
|
123
212
|
}
|
|
124
213
|
return section({
|
|
125
214
|
textAlign: alignMap[props.align || "center"],
|
|
126
|
-
...
|
|
215
|
+
...blockSpacing(props),
|
|
127
216
|
...(props.backgroundColor
|
|
128
217
|
? { backgroundColor: props.backgroundColor }
|
|
129
218
|
: {}),
|
|
@@ -136,7 +225,7 @@ const renderQuote = (props, context, palette) => {
|
|
|
136
225
|
: "";
|
|
137
226
|
return section({
|
|
138
227
|
borderLeft: `${props.borderWidth ?? 3}px solid ${props.borderColor || palette.GOLD}`,
|
|
139
|
-
...
|
|
228
|
+
...blockSpacing(props),
|
|
140
229
|
paddingLeft: (props.paddingX ?? 32) + 16,
|
|
141
230
|
...(props.marginLeft ? { marginLeft: `${props.marginLeft}px` } : {}),
|
|
142
231
|
...(props.backgroundColor
|
|
@@ -144,21 +233,41 @@ const renderQuote = (props, context, palette) => {
|
|
|
144
233
|
: {}),
|
|
145
234
|
}, quote + author);
|
|
146
235
|
};
|
|
147
|
-
const renderColumns = (props, context, palette) => {
|
|
148
|
-
const
|
|
149
|
-
|
|
236
|
+
const renderColumns = (props, context, palette, settings) => {
|
|
237
|
+
const gutter = props.gap ?? 8;
|
|
238
|
+
const columns = props.columns || [];
|
|
239
|
+
const cols = columns
|
|
240
|
+
.map((col, i) => {
|
|
241
|
+
const isLast = i === columns.length - 1;
|
|
242
|
+
const inner = (col.blocks || [])
|
|
243
|
+
.map((child) => renderBlock(child, context, palette, settings))
|
|
244
|
+
.join("");
|
|
245
|
+
return `<td width="50%" valign="top" style="width:50%;${isLast ? "" : `padding-right:${gutter}px;`}"><table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td>${inner}</td></tr></tbody></table></td>`;
|
|
246
|
+
})
|
|
150
247
|
.join("");
|
|
151
248
|
return section({
|
|
152
|
-
...
|
|
249
|
+
...blockSpacing(props),
|
|
153
250
|
...(props.backgroundColor
|
|
154
251
|
? { backgroundColor: props.backgroundColor }
|
|
155
252
|
: {}),
|
|
156
253
|
}, `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr>${cols}</tr></tbody></table>`);
|
|
157
254
|
};
|
|
255
|
+
const renderContainer = (props, context, palette, settings) => {
|
|
256
|
+
const inner = (props.blocks || [])
|
|
257
|
+
.map((child) => renderBlock(child, context, palette, settings))
|
|
258
|
+
.join("");
|
|
259
|
+
return section({
|
|
260
|
+
...blockSpacing(props),
|
|
261
|
+
...(props.backgroundColor
|
|
262
|
+
? { backgroundColor: props.backgroundColor }
|
|
263
|
+
: {}),
|
|
264
|
+
}, inner);
|
|
265
|
+
};
|
|
158
266
|
const renderDivider = (props) => {
|
|
267
|
+
const spacing = blockSpacing(props);
|
|
159
268
|
const style = props.height !== undefined
|
|
160
|
-
? { padding: `${props.height}px ${props.paddingX ?? 32}px` }
|
|
161
|
-
:
|
|
269
|
+
? { ...spacing, padding: `${props.height}px ${props.paddingX ?? 32}px` }
|
|
270
|
+
: spacing;
|
|
162
271
|
return section({
|
|
163
272
|
...style,
|
|
164
273
|
...(props.backgroundColor
|
|
@@ -174,7 +283,7 @@ const renderSpacer = (props) => section({
|
|
|
174
283
|
? { backgroundColor: props.backgroundColor }
|
|
175
284
|
: {}),
|
|
176
285
|
}, " ");
|
|
177
|
-
const
|
|
286
|
+
const renderFooterClassic = (props, context, palette) => {
|
|
178
287
|
const body = text(escapeHtml(props.text
|
|
179
288
|
? resolveVariables(props.text, context)
|
|
180
289
|
: "Recibes este correo por estar registrado en nuestra plataforma."), { color: "#f5f1e8", fontSize: 12, lineHeight: 1.6, margin: "0 0 12px" });
|
|
@@ -187,49 +296,640 @@ const renderFooter = (props, context, palette) => {
|
|
|
187
296
|
textAlign: "center",
|
|
188
297
|
}, body + brand);
|
|
189
298
|
};
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
299
|
+
const renderFooter = (props, context, palette, settings) => {
|
|
300
|
+
// "classic" (default) mantiene la barra oscura con text/brandName.
|
|
301
|
+
if (!props.variant || props.variant === "classic") {
|
|
302
|
+
return renderFooterClassic(props, context, palette);
|
|
303
|
+
}
|
|
304
|
+
// "one-column" / "two-columns": layout de columnas con bloques anidados.
|
|
305
|
+
return section({
|
|
306
|
+
textAlign: alignMap[props.align || "left"],
|
|
307
|
+
...blockSpacing(props),
|
|
308
|
+
...bgStyle(props),
|
|
309
|
+
}, renderColumnsTable(props.columns || [], props.gap ?? 12, context, palette, settings));
|
|
310
|
+
};
|
|
311
|
+
const chunkRows = (arr, size) => {
|
|
312
|
+
const out = [];
|
|
313
|
+
for (let i = 0; i < arr.length; i += size)
|
|
314
|
+
out.push(arr.slice(i, i + size));
|
|
315
|
+
return out;
|
|
316
|
+
};
|
|
317
|
+
const bgStyle = (props) => props.backgroundColor ? { backgroundColor: props.backgroundColor } : {};
|
|
318
|
+
const isRemoteAsset = (value) => /^(https?:)?\/\//.test(value) || value.startsWith("data:");
|
|
319
|
+
const renderSocial = (props, context, palette) => {
|
|
320
|
+
const accent = props.accentColor || palette.GOLD;
|
|
321
|
+
const logo = props.mode === "logo";
|
|
322
|
+
const size = props.iconSize ?? 32;
|
|
323
|
+
const half = (props.gap ?? 6) / 2;
|
|
324
|
+
const links = (props.links || [])
|
|
325
|
+
.map((l) => {
|
|
326
|
+
const href = resolveVariables(l.href, context);
|
|
327
|
+
if (logo) {
|
|
328
|
+
const icon = (l.icon || "").trim();
|
|
329
|
+
const inner = isRemoteAsset(icon)
|
|
330
|
+
? `<img src="${escapeHtml(resolveVariables(icon, context))}" alt="${escapeHtml(l.label)}" width="${size}" height="${size}" style="display:inline-block;border:0;border-radius:50%;width:${size}px;height:${size}px;object-fit:cover;" />`
|
|
331
|
+
: `<span style="${styleToString({ display: "inline-block", width: size, height: size, lineHeight: `${size}px`, textAlign: "center", borderRadius: "50%", backgroundColor: accent, color: palette.DARK, fontSize: Math.round(size * 0.4), fontWeight: 700 })}">${escapeHtml(icon || resolveVariables(l.label, context).slice(0, 2))}</span>`;
|
|
332
|
+
return linkOrSpan(href, `display:inline-block;margin:0 ${half}px;text-decoration:none;`, inner);
|
|
333
|
+
}
|
|
334
|
+
return linkOrSpan(href, styleToString({ color: props.color || palette.INK, fontSize: 13, fontWeight: 600, textDecoration: "none", margin: "0 10px", display: "inline-block" }), escapeHtml(resolveVariables(l.label, context)));
|
|
335
|
+
})
|
|
336
|
+
.join("");
|
|
337
|
+
return section({
|
|
338
|
+
textAlign: alignMap[props.align || "center"],
|
|
339
|
+
...blockSpacing(props),
|
|
340
|
+
...bgStyle(props),
|
|
341
|
+
}, links);
|
|
342
|
+
};
|
|
343
|
+
/** Gutter por defecto entre celdas de la galería (2 * 6px de padding). */
|
|
344
|
+
const GALLERY_GUTTER = 12;
|
|
345
|
+
/** `<img>` (o placeholder) de una imagen de la galería, con enlace opcional. */
|
|
346
|
+
const galleryImage = (img, context, palette, style) => {
|
|
347
|
+
const el = !img || !img.src
|
|
348
|
+
? text("(Imagen)", {
|
|
349
|
+
color: palette.CREAM_DIM,
|
|
350
|
+
fontSize: 12,
|
|
351
|
+
margin: 0,
|
|
352
|
+
})
|
|
353
|
+
: style.height > 0
|
|
354
|
+
? `<img src="${escapeHtml(resolveVariables(img.src, context))}" alt="${escapeHtml(img.alt || "")}" width="100%" height="${style.height}" style="${styleToString({ width: "100%", maxWidth: "100%", display: "block", border: 0, borderRadius: style.radius, height: style.height, objectFit: "cover" })}" />`
|
|
355
|
+
: `<img src="${escapeHtml(resolveVariables(img.src, context))}" alt="${escapeHtml(img.alt || "")}" width="240" style="max-width:100%;display:block;border:0;border-radius:${style.radius}px;" />`;
|
|
356
|
+
if (!img || !img.href)
|
|
357
|
+
return el;
|
|
358
|
+
return `<a href="${escapeHtml(resolveVariables(img.href, context))}" target="_blank" rel="noopener">${el}</a>`;
|
|
359
|
+
};
|
|
360
|
+
const galleryTable = (rows) => `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody>${rows}</tbody></table>`;
|
|
361
|
+
/** Cuadrícula clásica: filas de `columns` (2 o 3) imágenes. */
|
|
362
|
+
const renderGalleryGrid = (images, context, palette, style, columns) => {
|
|
363
|
+
const cols = columns === 3 ? 3 : 2;
|
|
364
|
+
const width = Math.floor(100 / cols);
|
|
365
|
+
const rows = chunkRows(images, cols)
|
|
366
|
+
.map((row) => {
|
|
367
|
+
const cells = row
|
|
368
|
+
.map((img) => {
|
|
369
|
+
const inner = galleryImage(img, context, palette, style);
|
|
370
|
+
return `<td width="${width}%" valign="top" style="width:${width}%;padding:${style.half}px;">${inner}</td>`;
|
|
371
|
+
})
|
|
372
|
+
.join("");
|
|
373
|
+
return `<tr>${cells}</tr>`;
|
|
374
|
+
})
|
|
375
|
+
.join("");
|
|
376
|
+
return galleryTable(rows);
|
|
377
|
+
};
|
|
378
|
+
/** Tres columnas en una sola fila. */
|
|
379
|
+
const renderGalleryColumnsLayout = (images, context, palette, style) => {
|
|
380
|
+
const width = Math.round((100 / 3) * 100) / 100;
|
|
381
|
+
const cells = [0, 1, 2]
|
|
382
|
+
.map((i) => {
|
|
383
|
+
// El gutter se reparte: la primera solo a la derecha, la última solo a la izquierda.
|
|
384
|
+
const padding = i === 0
|
|
385
|
+
? `padding-right:${style.half}px;`
|
|
386
|
+
: i === 2
|
|
387
|
+
? `padding-left:${style.half}px;`
|
|
388
|
+
: `padding-left:${style.half}px;padding-right:${style.half}px;`;
|
|
389
|
+
const inner = galleryImage(images[i], context, palette, style);
|
|
390
|
+
return `<td width="${width}%" valign="top" style="width:${width}%;${padding}">${inner}</td>`;
|
|
391
|
+
})
|
|
392
|
+
.join("");
|
|
393
|
+
return galleryTable(`<tr>${cells}</tr>`);
|
|
394
|
+
};
|
|
395
|
+
/** Dos apiladas a la izquierda + una alta a la derecha. */
|
|
396
|
+
const renderGalleryHorizontal = (images, context, palette, style) => {
|
|
397
|
+
const left = galleryTable(`<tr><td style="padding-bottom:${style.half}px;">${galleryImage(images[0], context, palette, style)}</td></tr>` +
|
|
398
|
+
`<tr><td style="padding-top:${style.half}px;">${galleryImage(images[1], context, palette, style)}</td></tr>`);
|
|
399
|
+
// La imagen alta cubre las dos apiladas más el gutter que las separa.
|
|
400
|
+
const tall = style.height > 0 ? style.height * 2 + style.half * 2 : style.height;
|
|
401
|
+
const right = galleryImage(images[2], context, palette, {
|
|
402
|
+
...style,
|
|
403
|
+
height: tall,
|
|
404
|
+
});
|
|
405
|
+
return galleryTable(`<tr><td width="50%" valign="top" style="width:50%;padding-right:${style.half}px;">${left}</td>` +
|
|
406
|
+
`<td width="50%" valign="top" style="width:50%;padding-left:${style.half}px;padding-top:${style.half}px;padding-bottom:${style.half}px;">${right}</td></tr>`);
|
|
407
|
+
};
|
|
408
|
+
/** Una imagen ancha arriba + dos columnas debajo. */
|
|
409
|
+
const renderGalleryVertical = (images, context, palette, style) => {
|
|
410
|
+
const hero = galleryImage(images[0], context, palette, style);
|
|
411
|
+
const below = [1, 2]
|
|
412
|
+
.map((i) => {
|
|
413
|
+
const inner = galleryImage(images[i], context, palette, style);
|
|
414
|
+
const padding = i === 1
|
|
415
|
+
? `padding-right:${style.half}px;`
|
|
416
|
+
: `padding-left:${style.half}px;`;
|
|
417
|
+
return `<td width="50%" valign="top" style="width:50%;padding-top:${style.half}px;${padding}">${inner}</td>`;
|
|
418
|
+
})
|
|
419
|
+
.join("");
|
|
420
|
+
return galleryTable(`<tr><td style="padding-bottom:${style.half}px;">${hero}</td></tr>` +
|
|
421
|
+
`<tr>${below}</tr>`);
|
|
422
|
+
};
|
|
423
|
+
const renderGallery = (props, context, palette) => {
|
|
424
|
+
const style = {
|
|
425
|
+
radius: typeof props.radius === "number" ? props.radius : 6,
|
|
426
|
+
height: typeof props.imageHeight === "number" ? props.imageHeight : 0,
|
|
427
|
+
half: (props.gap ?? GALLERY_GUTTER) / 2,
|
|
428
|
+
};
|
|
429
|
+
const images = props.images || [];
|
|
430
|
+
let content;
|
|
431
|
+
switch (props.variant) {
|
|
432
|
+
case "three-columns":
|
|
433
|
+
content = renderGalleryColumnsLayout(images, context, palette, style);
|
|
434
|
+
break;
|
|
435
|
+
case "horizontal":
|
|
436
|
+
content = renderGalleryHorizontal(images, context, palette, style);
|
|
437
|
+
break;
|
|
438
|
+
case "vertical":
|
|
439
|
+
content = renderGalleryVertical(images, context, palette, style);
|
|
440
|
+
break;
|
|
441
|
+
// "grid" (default) y cualquier variante desconocida.
|
|
442
|
+
default:
|
|
443
|
+
content = renderGalleryGrid(images, context, palette, style, props.columns ?? 2);
|
|
444
|
+
}
|
|
445
|
+
return section({ ...blockSpacing(props), ...bgStyle(props) }, content);
|
|
446
|
+
};
|
|
447
|
+
const renderStats = (props, context, palette) => {
|
|
448
|
+
const stats = props.stats || [];
|
|
449
|
+
const width = stats.length > 0 ? Math.floor(100 / stats.length) : 100;
|
|
450
|
+
const cells = stats
|
|
451
|
+
.map((s) => `<td width="${width}%" align="center" valign="top" style="width:${width}%;padding:8px;"><div style="${styleToString({ color: props.accentColor || palette.GOLD, fontSize: 28, fontWeight: 700, lineHeight: 1.1, margin: 0 })}">${renderRichText(resolveVariables(s.value, context))}</div><div style="${styleToString({ color: palette.INK, fontSize: 12, margin: "6px 0 0" })}">${renderRichText(resolveVariables(s.label, context))}</div></td>`)
|
|
452
|
+
.join("");
|
|
453
|
+
return section({
|
|
454
|
+
textAlign: alignMap[props.align || "center"],
|
|
455
|
+
...blockSpacing(props),
|
|
456
|
+
...bgStyle(props),
|
|
457
|
+
}, `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr>${cells}</tr></tbody></table>`);
|
|
458
|
+
};
|
|
459
|
+
const renderPricingCard = (props, context, palette) => {
|
|
460
|
+
const accent = props.accentColor || palette.GOLD;
|
|
461
|
+
const textColor = props.textColor || palette.INK;
|
|
462
|
+
const bullets = (props.bullets || [])
|
|
463
|
+
.map((b) => `<div style="${styleToString({ color: textColor, fontSize: 14, lineHeight: 1.6, margin: "0 0 6px" })}"><span style="${styleToString({ color: accent, fontWeight: 700 })}">✓</span> ${renderRichText(resolveVariables(b, context))}</div>`)
|
|
464
|
+
.join("");
|
|
465
|
+
const cta = props.ctaLabel
|
|
466
|
+
? linkOrSpan(resolveVariables(props.ctaHref, context), styleToString({ backgroundColor: accent, color: palette.DARK, fontWeight: 700, padding: "12px 24px", borderRadius: 999, fontSize: 14, textTransform: "uppercase", letterSpacing: "0.5px", display: "inline-block", textDecoration: "none", marginTop: 16 }), escapeHtml(resolveVariables(props.ctaLabel, context)))
|
|
467
|
+
: "";
|
|
468
|
+
const card = `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width:420px;margin:0 auto;"><tbody><tr><td style="${styleToString({ border: "1px solid #e3dccb", borderRadius: 10, padding: 24, textAlign: "center" })}"><div style="${styleToString({ color: accent, fontSize: 13, fontWeight: 700, textTransform: "uppercase", letterSpacing: "1px", margin: "0 0 8px" })}">${renderRichText(resolveVariables(props.title, context))}</div><div style="${styleToString({ color: textColor, fontSize: 36, fontWeight: 700, lineHeight: 1.1, margin: 0 })}">${renderRichText(resolveVariables(props.price, context))}<span style="${styleToString({ fontSize: 14, fontWeight: 400, color: palette.CREAM_DIM })}">${escapeHtml(props.period || "")}</span></div><div style="${styleToString({ margin: "16px 0 0", textAlign: "left" })}">${bullets}</div>${cta}</td></tr></tbody></table>`;
|
|
469
|
+
return section({ ...blockSpacing(props), ...bgStyle(props) }, card);
|
|
470
|
+
};
|
|
471
|
+
/** Botón ancho completo (email-safe: display:block). */
|
|
472
|
+
const pricingButton = (label, href, background, color, options, context) => {
|
|
473
|
+
if (!label)
|
|
474
|
+
return "";
|
|
475
|
+
return linkOrSpan(resolveVariables(href || "", context), styleToString({ backgroundColor: background, color, borderRadius: options.radius, boxSizing: "border-box", display: "block", fontSize: options.fontSize, lineHeight: options.lineHeight, fontWeight: options.fontWeight, letterSpacing: "0.025em", padding: options.padding, textAlign: "center", textDecoration: "none", width: "100%" }), escapeHtml(resolveVariables(label, context)));
|
|
476
|
+
};
|
|
477
|
+
const pricingRichList = (items, context, style) => `<ul style="${styleToString(style)}">${items
|
|
478
|
+
.map((item) => `<li style="margin-bottom:8px">${renderRichText(resolveVariables(item, context))}</li>`)
|
|
479
|
+
.join("")}</ul>`;
|
|
480
|
+
const renderPricingOffer = (props, context, palette) => {
|
|
481
|
+
const accent = props.accentColor || "#4f46e5";
|
|
482
|
+
const eyebrow = props.eyebrow
|
|
483
|
+
? `<div style="${styleToString({ color: accent, fontSize: 12, fontWeight: 600, letterSpacing: "0.025em", lineHeight: "20px", textTransform: "uppercase", margin: "0 0 16px" })}">${escapeHtml(resolveVariables(props.eyebrow, context))}</div>`
|
|
484
|
+
: "";
|
|
485
|
+
const price = `<div style="${styleToString({ fontSize: 30, fontWeight: 700, lineHeight: "36px", margin: "0 0 12px" })}"><span style="${styleToString({ color: "#101828" })}">${renderRichText(resolveVariables(props.price, context))}</span> <span style="${styleToString({ fontSize: 16, fontWeight: 500, lineHeight: "20px" })}">${escapeHtml(props.period || "")}</span></div>`;
|
|
486
|
+
const description = props.description
|
|
487
|
+
? `<div style="${styleToString({ color: "#374151", fontSize: 14, lineHeight: "20px", margin: "16px 0 24px" })}">${renderRichText(resolveVariables(props.description, context))}</div>`
|
|
488
|
+
: "";
|
|
489
|
+
const note = props.note
|
|
490
|
+
? `<div style="${styleToString({ color: "#6b7280", fontSize: 12, lineHeight: "16px", fontStyle: "italic", margin: "24px 0 6px", textAlign: "center" })}">${renderRichText(resolveVariables(props.note, context))}</div>`
|
|
491
|
+
: "";
|
|
492
|
+
const note2 = props.note2
|
|
493
|
+
? `<div style="${styleToString({ color: "#6b7280", fontSize: 12, lineHeight: "16px", margin: 0, textAlign: "center" })}">${renderRichText(resolveVariables(props.note2, context))}</div>`
|
|
494
|
+
: "";
|
|
495
|
+
const card = `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width:500px;margin:0 auto;"><tbody><tr><td style="${styleToString({ backgroundColor: "#ffffff", border: "1px solid #d1d5db", borderRadius: 12, padding: 28, textAlign: "left", color: "#4b5563" })}">${eyebrow}${price}${description}${pricingRichList(props.bullets || [], context, { color: "#6b7280", fontSize: 14, lineHeight: "24px", margin: "0 0 32px", paddingLeft: 14 })}${pricingButton(props.ctaLabel, props.ctaHref, accent, "#ffffff", { radius: 8, padding: "14px", fontWeight: 700, fontSize: 16, lineHeight: "24px" }, context)}<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0 0" />${note}${note2}</td></tr></tbody></table>`;
|
|
496
|
+
return section({ ...blockSpacing(props), ...bgStyle(props) }, card);
|
|
497
|
+
};
|
|
498
|
+
const renderPricingTwoTiers = (props, context, palette) => {
|
|
499
|
+
const plans = props.plans || [];
|
|
500
|
+
const fallbackWidth = plans.length > 0 ? 100 / plans.length : 100;
|
|
501
|
+
const cells = plans
|
|
502
|
+
.map((plan, i) => {
|
|
503
|
+
const isLast = i === plans.length - 1;
|
|
504
|
+
const hl = plan.highlighted === true;
|
|
505
|
+
const accent = hl ? "#7c86ff" : props.accentColor || "#4f46e5";
|
|
506
|
+
const width = String(Math.round(fallbackWidth * 100) / 100);
|
|
507
|
+
const card = `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td style="${styleToString({ backgroundColor: hl ? "#101828" : "#ffffff", border: `1px solid ${hl ? "#101828" : "#d1d5db"}`, borderRadius: 8, padding: 24, textAlign: "left", color: hl ? "#d1d5db" : "#4b5563" })}"><div style="${styleToString({ color: accent, fontSize: 14, fontWeight: 600, lineHeight: "20px", margin: "0 0 16px" })}">${renderRichText(resolveVariables(plan.title, context))}</div><div style="${styleToString({ fontSize: 28, fontWeight: 700, margin: "0 0 8px" })}"><span style="${styleToString({ color: hl ? "#ffffff" : "#101828" })}">${renderRichText(resolveVariables(plan.price, context))}</span> <span style="${styleToString({ fontSize: 14, lineHeight: "20px" })}">${escapeHtml(plan.period || "")}</span></div>${plan.description
|
|
508
|
+
? `<div style="${styleToString({ margin: "12px 0 24px" })}">${renderRichText(resolveVariables(plan.description, context))}</div>`
|
|
509
|
+
: ""}${pricingRichList(plan.features || [], context, { fontSize: 12, lineHeight: "20px", margin: "0 0 30px", paddingLeft: 14 })}${pricingButton(plan.ctaLabel, plan.ctaHref, "#4f46e5", "#ffffff", { radius: 8, padding: "12px", fontWeight: 600, fontSize: 14, lineHeight: "20px" }, context)}</td></tr></tbody></table>`;
|
|
510
|
+
return `<td width="${width}%" valign="top" style="width:${width}%;${isLast ? "" : "padding-right:20px;"}">${card}</td>`;
|
|
511
|
+
})
|
|
512
|
+
.join("");
|
|
513
|
+
const header = props.heading || props.subtitle
|
|
514
|
+
? `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td style="text-align:center;padding-bottom:32px;">${props.heading
|
|
515
|
+
? `<div style="${styleToString({ fontSize: 24, lineHeight: "32px", fontWeight: 700, margin: "0 0 12px" })}">${renderRichText(resolveVariables(props.heading, context))}</div>`
|
|
516
|
+
: ""}${props.subtitle
|
|
517
|
+
? `<div style="${styleToString({ color: "#6b7280", fontSize: 14, lineHeight: "20px", margin: 0 })}">${renderRichText(resolveVariables(props.subtitle, context))}</div>`
|
|
518
|
+
: ""}</td></tr></tbody></table>`
|
|
519
|
+
: "";
|
|
520
|
+
const footer = props.footnote
|
|
521
|
+
? `<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0 0" /><div style="${styleToString({ color: "#6b7280", fontSize: 12, lineHeight: "16px", fontWeight: 500, margin: "30px 0 0", textAlign: "center" })}">${renderRichText(resolveVariables(props.footnote, context))}</div>`
|
|
522
|
+
: "";
|
|
523
|
+
const cards = `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr>${cells}</tr></tbody></table>`;
|
|
524
|
+
return section({ ...blockSpacing(props), ...bgStyle(props) }, header + cards + footer);
|
|
525
|
+
};
|
|
526
|
+
const renderPricing = (props, context, palette) => {
|
|
527
|
+
if (props.variant === "offer") {
|
|
528
|
+
return renderPricingOffer(props, context, palette);
|
|
529
|
+
}
|
|
530
|
+
if (props.variant === "two-tiers") {
|
|
531
|
+
return renderPricingTwoTiers(props, context, palette);
|
|
532
|
+
}
|
|
533
|
+
return renderPricingCard(props, context, palette);
|
|
534
|
+
};
|
|
535
|
+
/** Botón de los diseños de `product` / `checkout` (estilo react.email). */
|
|
536
|
+
const productButton = (label, href, context, style) => linkOrSpan(resolveVariables(href, context), styleToString({ backgroundColor: "#4f46e5", color: "#ffffff", borderRadius: 8, padding: "12px 24px", fontSize: 16, fontWeight: 600, textDecoration: "none", display: "inline-block", textAlign: "center", ...style }), escapeHtml(resolveVariables(label, context)));
|
|
537
|
+
/** Imagen de producto con alto fijo y recorte (o placeholder). */
|
|
538
|
+
const productImage = (src, alt, context, palette, opts) => src
|
|
539
|
+
? `<img src="${escapeHtml(resolveVariables(src, context))}" alt="${escapeHtml(alt)}" width="100%" height="${opts.height}" style="${styleToString({ width: "100%", maxWidth: "100%", display: "block", border: 0, borderRadius: opts.radius, height: opts.height, objectFit: "cover" })}" />`
|
|
540
|
+
: text("(Imagen)", {
|
|
541
|
+
color: palette.CREAM_DIM,
|
|
542
|
+
fontSize: 12,
|
|
543
|
+
margin: 0,
|
|
544
|
+
});
|
|
545
|
+
/** Tarjeta clásica: imagen centrada + nombre + descripción + precio + CTA. */
|
|
546
|
+
const renderProductCard = (props, context, palette) => {
|
|
547
|
+
const image = props.imageUrl
|
|
548
|
+
? `<img src="${escapeHtml(resolveVariables(props.imageUrl, context))}" alt="${escapeHtml(props.name)}" width="360" style="max-width:100%;display:block;border:0;border-radius:${props.radius ?? 8}px;margin:0 auto 16px;" />`
|
|
549
|
+
: "";
|
|
550
|
+
const name = `<div style="${styleToString({ color: palette.DARK, fontSize: 22, fontWeight: 700, margin: "0 0 8px" })}">${renderRichText(resolveVariables(props.name, context))}</div>`;
|
|
551
|
+
const desc = props.description
|
|
552
|
+
? `<div style="${styleToString({ color: palette.INK, fontSize: 14, lineHeight: 1.6, margin: "0 0 12px" })}">${renderRichText(resolveVariables(props.description, context))}</div>`
|
|
553
|
+
: "";
|
|
554
|
+
const price = `<div style="${styleToString({ color: palette.GOLD, fontSize: 20, fontWeight: 700, margin: "0 0 16px" })}">${renderRichText(resolveVariables(props.price, context))}</div>`;
|
|
555
|
+
const cta = props.ctaLabel
|
|
556
|
+
? linkOrSpan(resolveVariables(props.ctaHref, context), styleToString({ backgroundColor: palette.GOLD, color: palette.DARK, fontWeight: 700, padding: "12px 24px", borderRadius: 999, fontSize: 14, textTransform: "uppercase", letterSpacing: "0.5px", display: "inline-block", textDecoration: "none" }), escapeHtml(resolveVariables(props.ctaLabel, context)))
|
|
557
|
+
: "";
|
|
558
|
+
return section({
|
|
559
|
+
textAlign: alignMap[props.align || "center"],
|
|
560
|
+
...blockSpacing(props),
|
|
561
|
+
...bgStyle(props),
|
|
562
|
+
}, image + name + desc + price + cta);
|
|
563
|
+
};
|
|
564
|
+
/** "One product": imagen ancha arriba y el texto centrado debajo. */
|
|
565
|
+
const renderProductHero = (props, context, palette) => {
|
|
566
|
+
const accent = props.accentColor || palette.GOLD;
|
|
567
|
+
const height = props.imageHeight && props.imageHeight > 0 ? props.imageHeight : 320;
|
|
568
|
+
const radius = props.radius ?? 12;
|
|
569
|
+
const image = productImage(props.imageUrl, props.name, context, palette, {
|
|
570
|
+
height,
|
|
571
|
+
radius,
|
|
572
|
+
});
|
|
573
|
+
const eyebrow = props.eyebrow
|
|
574
|
+
? `<div style="${styleToString({ color: accent, fontSize: 18, fontWeight: 600, lineHeight: 1.55, margin: "16px 0 0" })}">${renderRichText(resolveVariables(props.eyebrow, context))}</div>`
|
|
575
|
+
: "";
|
|
576
|
+
const name = `<div style="${styleToString({ color: palette.DARK, fontSize: 36, fontWeight: 600, lineHeight: 1.11, letterSpacing: "0.4px", margin: "8px 0 0" })}">${renderRichText(resolveVariables(props.name, context))}</div>`;
|
|
577
|
+
const desc = props.description
|
|
578
|
+
? `<div style="${styleToString({ color: palette.INK, fontSize: 16, lineHeight: 1.5, margin: "8px 0 0" })}">${renderRichText(resolveVariables(props.description, context))}</div>`
|
|
579
|
+
: "";
|
|
580
|
+
const price = props.price
|
|
581
|
+
? `<div style="${styleToString({ color: palette.DARK, fontSize: 16, fontWeight: 600, lineHeight: 1.5, margin: "8px 0 0" })}">${renderRichText(resolveVariables(props.price, context))}</div>`
|
|
582
|
+
: "";
|
|
583
|
+
const cta = props.ctaLabel
|
|
584
|
+
? productButton(props.ctaLabel, props.ctaHref, context, {
|
|
585
|
+
backgroundColor: accent,
|
|
586
|
+
marginTop: 16,
|
|
587
|
+
})
|
|
588
|
+
: "";
|
|
589
|
+
return section({
|
|
590
|
+
textAlign: alignMap[props.align || "center"],
|
|
591
|
+
...blockSpacing(props),
|
|
592
|
+
...bgStyle(props),
|
|
593
|
+
}, image + eyebrow + name + desc + price + cta);
|
|
594
|
+
};
|
|
595
|
+
/** Imagen a la izquierda (50%) y ficha del producto a la derecha. */
|
|
596
|
+
const renderProductImageLeft = (props, context, palette) => {
|
|
597
|
+
const accent = props.accentColor || palette.GOLD;
|
|
598
|
+
const height = props.imageHeight && props.imageHeight > 0 ? props.imageHeight : 220;
|
|
599
|
+
const radius = props.radius ?? 8;
|
|
600
|
+
const image = productImage(props.imageUrl, props.name, context, palette, {
|
|
601
|
+
height,
|
|
602
|
+
radius,
|
|
603
|
+
});
|
|
604
|
+
const name = `<div style="${styleToString({ color: palette.DARK, fontSize: 20, fontWeight: 600, lineHeight: 1.4, margin: "8px 0 0" })}">${renderRichText(resolveVariables(props.name, context))}</div>`;
|
|
605
|
+
const desc = props.description
|
|
606
|
+
? `<div style="${styleToString({ color: palette.INK, fontSize: 16, lineHeight: 1.5, margin: "8px 0 0" })}">${renderRichText(resolveVariables(props.description, context))}</div>`
|
|
607
|
+
: "";
|
|
608
|
+
const price = props.price
|
|
609
|
+
? `<div style="${styleToString({ color: palette.DARK, fontSize: 18, fontWeight: 600, lineHeight: 1.55, margin: "8px 0 0" })}">${renderRichText(resolveVariables(props.price, context))}</div>`
|
|
610
|
+
: "";
|
|
611
|
+
const cta = props.ctaLabel
|
|
612
|
+
? productButton(props.ctaLabel, props.ctaHref, context, {
|
|
613
|
+
backgroundColor: accent,
|
|
614
|
+
width: "75%",
|
|
615
|
+
display: "block",
|
|
616
|
+
boxSizing: "border-box",
|
|
617
|
+
marginTop: 16,
|
|
618
|
+
padding: "12px 16px",
|
|
619
|
+
})
|
|
620
|
+
: "";
|
|
621
|
+
return section({
|
|
622
|
+
...blockSpacing(props),
|
|
623
|
+
...bgStyle(props),
|
|
624
|
+
}, `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td width="50%" valign="top" style="width:50%;padding-right:32px;box-sizing:border-box;">${image}</td><td width="50%" valign="top" style="width:50%;vertical-align:baseline;">${name}${desc}${price}${cta}</td></tr></tbody></table>`);
|
|
625
|
+
};
|
|
626
|
+
/** Filas de tarjetas: encabezado opcional + 2/3/4 tarjetas (4 = 2×2 con Hr). */
|
|
627
|
+
const renderProductGrid = (props, context, palette) => {
|
|
628
|
+
const items = props.products || [];
|
|
629
|
+
const cols = props.columns === 3 ? 3 : props.columns === 4 ? 4 : 2;
|
|
630
|
+
// Con 4 tarjetas react.email usa 2×2 con separador; con 3, una sola fila.
|
|
631
|
+
const perRow = cols === 4 ? 2 : cols;
|
|
632
|
+
const gutter = cols === 3 ? 4 : 8;
|
|
633
|
+
const accent = props.accentColor || palette.GOLD;
|
|
634
|
+
const radius = props.radius ?? 8;
|
|
635
|
+
const height = props.imageHeight && props.imageHeight > 0
|
|
636
|
+
? props.imageHeight
|
|
637
|
+
: cols === 3
|
|
638
|
+
? 180
|
|
639
|
+
: 250;
|
|
640
|
+
const card = (item) => {
|
|
641
|
+
const image = productImage(item.imageUrl, item.name, context, palette, {
|
|
642
|
+
height,
|
|
643
|
+
radius,
|
|
644
|
+
});
|
|
645
|
+
const name = `<div style="${styleToString({ color: palette.DARK, fontSize: 20, fontWeight: 600, lineHeight: 1.4, margin: "24px 0 0" })}">${renderRichText(resolveVariables(item.name, context))}</div>`;
|
|
646
|
+
const desc = item.description
|
|
647
|
+
? `<div style="${styleToString({ color: palette.INK, fontSize: 16, lineHeight: 1.5, margin: "16px 0 0" })}">${renderRichText(resolveVariables(item.description, context))}</div>`
|
|
648
|
+
: "";
|
|
649
|
+
const price = item.price
|
|
650
|
+
? `<div style="${styleToString({ color: palette.DARK, fontSize: 16, fontWeight: 600, lineHeight: 1.5, margin: "8px 0 0" })}">${renderRichText(resolveVariables(item.price, context))}</div>`
|
|
651
|
+
: "";
|
|
652
|
+
const cta = item.ctaLabel
|
|
653
|
+
? productButton(item.ctaLabel, item.ctaHref || "", context, {
|
|
654
|
+
backgroundColor: accent,
|
|
655
|
+
marginTop: 16,
|
|
656
|
+
})
|
|
657
|
+
: "";
|
|
658
|
+
return image + name + desc + price + cta;
|
|
659
|
+
};
|
|
660
|
+
const rows = chunkRows(items, perRow)
|
|
661
|
+
.map((row, ri) => {
|
|
662
|
+
const cells = row
|
|
663
|
+
.map((item, ci) => {
|
|
664
|
+
const first = ci === 0;
|
|
665
|
+
const last = ci === row.length - 1;
|
|
666
|
+
const padding = (first ? "" : `padding-left:${gutter}px;`) +
|
|
667
|
+
(last ? "" : `padding-right:${gutter}px;`);
|
|
668
|
+
const width = String(Math.round((100 / perRow) * 100) / 100);
|
|
669
|
+
return `<td width="${width}%" valign="top" style="width:${width}%;padding-top:16px;padding-bottom:16px;${padding}">${card(item)}</td>`;
|
|
670
|
+
})
|
|
671
|
+
.join("");
|
|
672
|
+
const separator = ri === 0 && chunkRows(items, perRow).length > 1
|
|
673
|
+
? `<tr><td colspan="${row.length}" style="padding:0;"><hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;" /></td></tr>`
|
|
674
|
+
: "";
|
|
675
|
+
return `<tr>${cells}</tr>${separator}`;
|
|
676
|
+
})
|
|
677
|
+
.join("");
|
|
678
|
+
const header = props.heading || props.subheading
|
|
679
|
+
? `<div style="padding-bottom:16px;">${props.heading
|
|
680
|
+
? `<div style="${styleToString({ color: palette.DARK, fontSize: 20, fontWeight: 600, lineHeight: 1.4, margin: 0 })}">${renderRichText(resolveVariables(props.heading, context))}</div>`
|
|
681
|
+
: ""}${props.subheading
|
|
682
|
+
? `<div style="${styleToString({ color: palette.INK, fontSize: 16, lineHeight: 1.5, margin: "8px 0 0" })}">${renderRichText(resolveVariables(props.subheading, context))}</div>`
|
|
683
|
+
: ""}</div>`
|
|
684
|
+
: "";
|
|
685
|
+
return section({
|
|
686
|
+
textAlign: alignMap[props.align || "left"],
|
|
687
|
+
...blockSpacing(props),
|
|
688
|
+
...bgStyle(props),
|
|
689
|
+
}, `${header}<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody>${rows}</tbody></table>`);
|
|
690
|
+
};
|
|
691
|
+
const renderProduct = (props, context, palette) => {
|
|
692
|
+
switch (props.variant) {
|
|
194
693
|
case "hero":
|
|
195
|
-
return
|
|
196
|
-
case "
|
|
197
|
-
return
|
|
198
|
-
case "
|
|
199
|
-
return
|
|
200
|
-
|
|
201
|
-
return renderList(block.props, context, palette);
|
|
202
|
-
case "button":
|
|
203
|
-
return renderButton(block.props, context, palette);
|
|
204
|
-
case "image":
|
|
205
|
-
return renderImage(block.props, context, palette);
|
|
206
|
-
case "quote":
|
|
207
|
-
return renderQuote(block.props, context, palette);
|
|
208
|
-
case "columns":
|
|
209
|
-
return renderColumns(block.props, context, palette);
|
|
210
|
-
case "divider":
|
|
211
|
-
return renderDivider(block.props);
|
|
212
|
-
case "spacer":
|
|
213
|
-
return renderSpacer(block.props);
|
|
214
|
-
case "footer":
|
|
215
|
-
return renderFooter(block.props, context, palette);
|
|
694
|
+
return renderProductHero(props, context, palette);
|
|
695
|
+
case "image-left":
|
|
696
|
+
return renderProductImageLeft(props, context, palette);
|
|
697
|
+
case "grid":
|
|
698
|
+
return renderProductGrid(props, context, palette);
|
|
699
|
+
// "card" (default) y cualquier variante desconocida.
|
|
216
700
|
default:
|
|
217
|
-
return
|
|
701
|
+
return renderProductCard(props, context, palette);
|
|
218
702
|
}
|
|
219
703
|
};
|
|
704
|
+
/** Resumen de pedido: tabla de líneas + botón de compra full-width. */
|
|
705
|
+
const renderCheckout = (props, context, palette) => {
|
|
706
|
+
const accent = props.accentColor || "#4f46e5";
|
|
707
|
+
const border = props.borderColor || "#e5e7eb";
|
|
708
|
+
const height = props.imageHeight && props.imageHeight > 0 ? props.imageHeight : 110;
|
|
709
|
+
const radius = props.radius ?? 8;
|
|
710
|
+
const cell = `padding:8px 0;border-bottom:1px solid ${border};`;
|
|
711
|
+
const head = (label, align) => `<th align="${align}" style="${cell}${styleToString({ color: "#6b7280", fontSize: 14, fontWeight: 600, textAlign: align })}">${escapeHtml(label)}</th>`;
|
|
712
|
+
const rows = (props.lines || [])
|
|
713
|
+
.map((line) => {
|
|
714
|
+
const image = line.imageUrl
|
|
715
|
+
? `<img src="${escapeHtml(resolveVariables(line.imageUrl, context))}" alt="${escapeHtml(line.name)}" width="${height}" height="${height}" style="${styleToString({ width: height, height, borderRadius: radius, objectFit: "cover", border: 0, display: "block" })}" />`
|
|
716
|
+
: text("(Imagen)", { color: palette.CREAM_DIM, fontSize: 12, margin: 0 });
|
|
717
|
+
const name = `<div style="${styleToString({ color: palette.DARK, fontSize: 14, margin: 0 })}">${renderRichText(resolveVariables(line.name, context))}</div>`;
|
|
718
|
+
const qty = `<div style="${styleToString({ color: palette.DARK, fontSize: 14, margin: 0, textAlign: "center" })}">${escapeHtml(resolveVariables(line.quantity || "1", context))}</div>`;
|
|
719
|
+
const price = `<div style="${styleToString({ color: palette.DARK, fontSize: 14, margin: 0, textAlign: "center" })}">${escapeHtml(resolveVariables(line.price || "", context))}</div>`;
|
|
720
|
+
return `<tr><td style="${cell}">${image}</td><td align="left" style="${cell}">${name}</td><td align="center" style="${cell}">${qty}</td><td align="center" style="${cell}">${price}</td></tr>`;
|
|
721
|
+
})
|
|
722
|
+
.join("");
|
|
723
|
+
const table = `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom:16px;"><tbody><tr><th style="${cell}"> </th>${head("Producto", "left")}${head("Cantidad", "center")}${head("Precio", "center")}</tr>${rows}</tbody></table>`;
|
|
724
|
+
const cta = props.ctaLabel
|
|
725
|
+
? productButton(props.ctaLabel, props.ctaHref || "", context, {
|
|
726
|
+
backgroundColor: accent,
|
|
727
|
+
display: "block",
|
|
728
|
+
width: "100%",
|
|
729
|
+
boxSizing: "border-box",
|
|
730
|
+
padding: "12px",
|
|
731
|
+
})
|
|
732
|
+
: "";
|
|
733
|
+
const box = `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td style="${styleToString({ border: `1px solid ${border}`, borderRadius: 8, padding: "0 16px 16px", marginTop: 16 })}">${table}${cta}</td></tr></tbody></table>`;
|
|
734
|
+
const heading = props.heading
|
|
735
|
+
? `<div style="${styleToString({ color: palette.DARK, fontSize: 30, fontWeight: 600, lineHeight: 1.2, margin: "0 0 16px", textAlign: "center" })}">${renderRichText(resolveVariables(props.heading, context))}</div>`
|
|
736
|
+
: "";
|
|
737
|
+
return section({
|
|
738
|
+
textAlign: alignMap[props.align || "center"],
|
|
739
|
+
...blockSpacing(props),
|
|
740
|
+
...bgStyle(props),
|
|
741
|
+
}, heading + box);
|
|
742
|
+
};
|
|
743
|
+
const renderTestimonial = (props, context, palette) => {
|
|
744
|
+
const avatar = props.avatarUrl
|
|
745
|
+
? `<img src="${escapeHtml(resolveVariables(props.avatarUrl, context))}" alt="${escapeHtml(props.name)}" width="56" height="56" style="width:56px;height:56px;border-radius:50%;display:block;margin:0 0 12px;border:0;object-fit:cover;" />`
|
|
746
|
+
: "";
|
|
747
|
+
const quote = `<div style="${styleToString({ color: palette.INK, fontStyle: "italic", fontSize: 16, lineHeight: 1.6, margin: 0 })}">\u201C${renderRichText(resolveVariables(props.quote, context))}\u201D</div>`;
|
|
748
|
+
const name = `<div style="${styleToString({ color: props.accentColor || palette.GOLD, fontSize: 14, fontWeight: 700, margin: "12px 0 0" })}">${renderRichText(resolveVariables(props.name, context))}</div>`;
|
|
749
|
+
const role = props.role
|
|
750
|
+
? `<div style="${styleToString({ color: palette.CREAM_DIM, fontSize: 12, margin: "2px 0 0" })}">${renderRichText(resolveVariables(props.role, context))}</div>`
|
|
751
|
+
: "";
|
|
752
|
+
return section({
|
|
753
|
+
textAlign: alignMap[props.align || "left"],
|
|
754
|
+
...blockSpacing(props),
|
|
755
|
+
...bgStyle(props),
|
|
756
|
+
}, avatar + quote + name + role);
|
|
757
|
+
};
|
|
758
|
+
const renderFeatures = (props, context, palette) => {
|
|
759
|
+
const cols = props.columns === 3 ? 3 : 2;
|
|
760
|
+
const width = Math.floor(100 / cols);
|
|
761
|
+
const accent = props.accentColor || palette.GOLD;
|
|
762
|
+
const rows = chunkRows(props.features || [], cols)
|
|
763
|
+
.map((row) => {
|
|
764
|
+
const cells = row
|
|
765
|
+
.map((f) => `<td width="${width}%" valign="top" style="width:${width}%;padding:10px 10px 16px 0;"><div style="${styleToString({ color: accent, fontSize: 20, fontWeight: 700, margin: "0 0 6px" })}">${escapeHtml(f.icon || "✓")}</div><div style="${styleToString({ color: palette.DARK, fontSize: 15, fontWeight: 700, margin: "0 0 4px" })}">${renderRichText(resolveVariables(f.title, context))}</div><div style="${styleToString({ color: palette.INK, fontSize: 13, lineHeight: 1.5, margin: 0 })}">${renderRichText(resolveVariables(f.description, context))}</div></td>`)
|
|
766
|
+
.join("");
|
|
767
|
+
return `<tr>${cells}</tr>`;
|
|
768
|
+
})
|
|
769
|
+
.join("");
|
|
770
|
+
return section({
|
|
771
|
+
textAlign: alignMap[props.align || "left"],
|
|
772
|
+
...blockSpacing(props),
|
|
773
|
+
...bgStyle(props),
|
|
774
|
+
}, `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody>${rows}</tbody></table>`);
|
|
775
|
+
};
|
|
776
|
+
const renderAvatar = (props, context, palette) => {
|
|
777
|
+
const size = props.size || 96;
|
|
778
|
+
const image = props.imageUrl
|
|
779
|
+
? `<img src="${escapeHtml(resolveVariables(props.imageUrl, context))}" alt="${escapeHtml(props.name)}" width="${size}" height="${size}" style="width:${size}px;height:${size}px;border-radius:50%;display:inline-block;border:0;object-fit:cover;" />`
|
|
780
|
+
: `<div style="${styleToString({ width: size, height: size, borderRadius: "50%", backgroundColor: palette.CREAM_DIM, display: "inline-block" })}"></div>`;
|
|
781
|
+
const name = `<div style="${styleToString({ color: palette.DARK, fontSize: 16, fontWeight: 700, margin: "12px 0 0" })}">${renderRichText(resolveVariables(props.name, context))}</div>`;
|
|
782
|
+
const role = props.role
|
|
783
|
+
? `<div style="${styleToString({ color: palette.CREAM_DIM, fontSize: 13, margin: "2px 0 0" })}">${renderRichText(resolveVariables(props.role, context))}</div>`
|
|
784
|
+
: "";
|
|
785
|
+
return section({
|
|
786
|
+
textAlign: alignMap[props.align || "center"],
|
|
787
|
+
...blockSpacing(props),
|
|
788
|
+
...bgStyle(props),
|
|
789
|
+
}, image + name + role);
|
|
790
|
+
};
|
|
791
|
+
const renderCode = (props) => section({ ...blockSpacing(props) }, `<pre style="${styleToString({ backgroundColor: props.backgroundColor || DEFAULT_PALETTE.DARK, color: props.color || "#f5f1e8", padding: 16, borderRadius: 8, fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", fontSize: 13, lineHeight: 1.5, margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word" })}">${escapeHtml(props.code || "")}</pre>`);
|
|
792
|
+
const renderLink = (props, context, palette) => section({
|
|
793
|
+
textAlign: alignMap[props.align || "left"],
|
|
794
|
+
...blockSpacing(props),
|
|
795
|
+
...bgStyle(props),
|
|
796
|
+
}, linkOrSpan(resolveVariables(props.href, context), styleToString({ color: props.color || palette.GOLD, fontSize: 15, fontWeight: 600, textDecoration: "underline" }), escapeHtml(resolveVariables(props.label, context))));
|
|
797
|
+
/**
|
|
798
|
+
* Lista de archivos descargables. Los archivos NO viven en el bloque: se suben
|
|
799
|
+
* una vez en Ajustes (`settings.files`) y este bloque los pinta donde lo pongas.
|
|
800
|
+
* Solo lista los marcados como enlace (`link !== false`); los que son solo
|
|
801
|
+
* adjuntos (`attach: true, link: false`) no aparecen en el HTML.
|
|
802
|
+
*/
|
|
803
|
+
const renderDownloads = (props, context, palette, settings) => {
|
|
804
|
+
const accent = props.accentColor || palette.GOLD;
|
|
805
|
+
const border = props.borderColor || "#e3dccb";
|
|
806
|
+
const label = props.buttonLabel || "Descargar";
|
|
807
|
+
const files = (settings.files ?? []).filter((file) => file.link !== false && isSafeFileUrl(file.url));
|
|
808
|
+
const headingText = String(props.heading ?? "");
|
|
809
|
+
const subheadingText = String(props.subheading ?? "");
|
|
810
|
+
const emptyText = String(props.emptyText ?? "");
|
|
811
|
+
// Sin archivos y sin mensaje: no pintar nada (evita encabezados huérfanos).
|
|
812
|
+
if (files.length === 0 && !emptyText)
|
|
813
|
+
return "";
|
|
814
|
+
const cell = `padding:10px 0;border-bottom:1px solid ${border};`;
|
|
815
|
+
const rows = files
|
|
816
|
+
.map((file) => {
|
|
817
|
+
const kind = fileKind(file.name || file.url, file.mimeType);
|
|
818
|
+
const badge = props.showIcon === false
|
|
819
|
+
? ""
|
|
820
|
+
: `<td width="46" valign="middle" style="${cell}"><span style="${styleToString({ display: "inline-block", minWidth: 38, padding: "3px 6px", borderRadius: 6, backgroundColor: "#f1e8dc", color: accent, fontSize: 11, fontWeight: 700, textAlign: "center" })}">${escapeHtml(FILE_KIND_LABELS[kind])}</span></td>`;
|
|
821
|
+
const size = props.showSize === false ? "" : formatBytes(file.size);
|
|
822
|
+
const meta = size
|
|
823
|
+
? `<div style="${styleToString({ color: palette.CREAM_DIM, fontSize: 12, margin: "2px 0 0" })}">${escapeHtml(size)}</div>`
|
|
824
|
+
: "";
|
|
825
|
+
const name = `<div style="${styleToString({ color: palette.DARK, fontSize: 14, fontWeight: 600, margin: 0, wordBreak: "break-word" })}">${escapeHtml(file.name || "archivo")}</div>`;
|
|
826
|
+
const download = linkOrSpan(resolveVariables(file.url, context), styleToString({
|
|
827
|
+
color: accent,
|
|
828
|
+
fontSize: 14,
|
|
829
|
+
fontWeight: 600,
|
|
830
|
+
textDecoration: "underline",
|
|
831
|
+
whiteSpace: "nowrap",
|
|
832
|
+
}), escapeHtml(label));
|
|
833
|
+
return `<tr>${badge}<td valign="middle" style="${cell}">${name}${meta}</td><td valign="middle" align="right" style="${cell}">${download}</td></tr>`;
|
|
834
|
+
})
|
|
835
|
+
.join("");
|
|
836
|
+
const heading = headingText
|
|
837
|
+
? `<div style="${styleToString({ color: palette.DARK, fontSize: 20, fontWeight: 700, lineHeight: 1.3, margin: 0 })}">${renderRichText(resolveVariables(headingText, context))}</div>`
|
|
838
|
+
: "";
|
|
839
|
+
const subheading = subheadingText
|
|
840
|
+
? `<div style="${styleToString({ color: palette.CREAM_DIM, fontSize: 14, margin: "6px 0 0" })}">${renderRichText(resolveVariables(subheadingText, context))}</div>`
|
|
841
|
+
: "";
|
|
842
|
+
const header = heading || subheading ? `<div style="margin-bottom:12px;">${heading}${subheading}</div>` : "";
|
|
843
|
+
const empty = files.length === 0
|
|
844
|
+
? `<div style="${styleToString({ color: palette.CREAM_DIM, fontSize: 14, margin: 0 })}">${escapeHtml(emptyText)}</div>`
|
|
845
|
+
: "";
|
|
846
|
+
const table = files.length
|
|
847
|
+
? `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody>${rows}</tbody></table>`
|
|
848
|
+
: "";
|
|
849
|
+
const box = `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td style="${styleToString({ border: `1px solid ${border}`, borderRadius: props.radius ?? 8, padding: "4px 16px" })}">${empty}${table}</td></tr></tbody></table>`;
|
|
850
|
+
return section({
|
|
851
|
+
textAlign: alignMap[props.align || "left"],
|
|
852
|
+
...blockSpacing(props),
|
|
853
|
+
...bgStyle(props),
|
|
854
|
+
}, header + box);
|
|
855
|
+
};
|
|
856
|
+
/** Tabla de columnas (celdas con ancho y gutter) compartida por grid/footer. */
|
|
857
|
+
const renderColumnsTable = (columns, gutter, context, palette, settings) => {
|
|
858
|
+
const fallbackWidth = columns.length > 0 ? 100 / columns.length : 100;
|
|
859
|
+
const cells = columns
|
|
860
|
+
.map((col, i) => {
|
|
861
|
+
const isLast = i === columns.length - 1;
|
|
862
|
+
const width = String(Math.round((col.width ?? fallbackWidth) * 100) / 100);
|
|
863
|
+
const inner = (col.blocks || [])
|
|
864
|
+
.map((child) => renderBlock(child, context, palette, settings))
|
|
865
|
+
.join("");
|
|
866
|
+
return `<td width="${width}%" valign="top" style="width:${width}%;${isLast ? "" : `padding-right:${gutter}px;`}"><table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td>${inner}</td></tr></tbody></table></td>`;
|
|
867
|
+
})
|
|
868
|
+
.join("");
|
|
869
|
+
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr>${cells}</tr></tbody></table>`;
|
|
870
|
+
};
|
|
871
|
+
const renderGrid = (props, context, palette, settings) => section({
|
|
872
|
+
...blockSpacing(props),
|
|
873
|
+
...bgStyle(props),
|
|
874
|
+
}, renderColumnsTable(props.columns || [], props.gap ?? 0, context, palette, settings));
|
|
875
|
+
/** Renderers built-in por tipo — se registran en el registry al importar. */
|
|
876
|
+
const BUILTIN_RENDERERS = {
|
|
877
|
+
header: renderHeader,
|
|
878
|
+
hero: renderHero,
|
|
879
|
+
heading: renderHeading,
|
|
880
|
+
text: renderText,
|
|
881
|
+
list: renderList,
|
|
882
|
+
button: renderButton,
|
|
883
|
+
image: renderImage,
|
|
884
|
+
quote: renderQuote,
|
|
885
|
+
columns: renderColumns,
|
|
886
|
+
container: renderContainer,
|
|
887
|
+
grid: renderGrid,
|
|
888
|
+
divider: renderDivider,
|
|
889
|
+
spacer: renderSpacer,
|
|
890
|
+
footer: renderFooter,
|
|
891
|
+
social: renderSocial,
|
|
892
|
+
gallery: renderGallery,
|
|
893
|
+
stats: renderStats,
|
|
894
|
+
pricing: renderPricing,
|
|
895
|
+
product: renderProduct,
|
|
896
|
+
checkout: renderCheckout,
|
|
897
|
+
testimonial: renderTestimonial,
|
|
898
|
+
features: renderFeatures,
|
|
899
|
+
avatar: renderAvatar,
|
|
900
|
+
code: renderCode,
|
|
901
|
+
link: renderLink,
|
|
902
|
+
downloads: renderDownloads,
|
|
903
|
+
};
|
|
904
|
+
for (const definition of DEFAULT_BLOCK_LIBRARY) {
|
|
905
|
+
const renderer = BUILTIN_RENDERERS[definition.type];
|
|
906
|
+
registerBlock({
|
|
907
|
+
definition,
|
|
908
|
+
renderHtml: ({ props, context, palette, settings }) => renderer(props, context, palette, settings ?? {}),
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
const renderBlock = (block, context, palette, settings) => {
|
|
912
|
+
const renderHtml = getBlockRenderHtml(block.type);
|
|
913
|
+
if (!renderHtml)
|
|
914
|
+
return "";
|
|
915
|
+
return renderHtml({ props: block.props, context, palette, settings });
|
|
916
|
+
};
|
|
220
917
|
/**
|
|
221
918
|
* Renderiza el payload de bloques a HTML email-safe sin React.
|
|
222
919
|
* Equivalente funcional de `renderEmailHtml` basado en react-email.
|
|
223
920
|
*/
|
|
224
|
-
export const renderEmailHtml = async ({ blocks, subject = "", context = SAMPLE_CONTEXT, settings = {}, palette = DEFAULT_PALETTE, }) => {
|
|
921
|
+
export const renderEmailHtml = async ({ blocks, subject = "", context = SAMPLE_CONTEXT, settings = {}, palette = DEFAULT_PALETTE, tracking, }) => {
|
|
225
922
|
const pageBackground = settings.pageBackground ?? DEFAULT_SETTINGS.pageBackground;
|
|
226
923
|
const cardBorderWidth = settings.cardBorderWidth ?? DEFAULT_SETTINGS.cardBorderWidth;
|
|
227
924
|
const cardBorderRadius = settings.cardBorderRadius ?? DEFAULT_SETTINGS.cardBorderRadius;
|
|
925
|
+
const fontFamily = settings.fontFamily ?? DEFAULT_SETTINGS.fontFamily;
|
|
926
|
+
// `""` desactiva la tipografía explícita (se hereda la del cliente).
|
|
927
|
+
const font = fontFamily ? { fontFamily } : {};
|
|
228
928
|
const resolvedSubject = resolveVariables(subject, context);
|
|
229
929
|
const body = blocks
|
|
230
|
-
.map((block) => renderBlock(block, context, palette))
|
|
930
|
+
.map((block) => renderBlock(block, context, palette, settings))
|
|
231
931
|
.join("");
|
|
232
|
-
|
|
932
|
+
const html = `<!DOCTYPE html>
|
|
233
933
|
<html dir="ltr" lang="es">
|
|
234
934
|
<head>
|
|
235
935
|
<meta charset="utf-8" />
|
|
@@ -237,12 +937,13 @@ export const renderEmailHtml = async ({ blocks, subject = "", context = SAMPLE_C
|
|
|
237
937
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
238
938
|
<title>${escapeHtml(resolvedSubject)}</title>
|
|
239
939
|
</head>
|
|
240
|
-
<body style="${styleToString({ margin: 0, padding: 0, backgroundColor: pageBackground })}">
|
|
940
|
+
<body style="${styleToString({ margin: 0, padding: 0, backgroundColor: pageBackground, ...font })}">
|
|
241
941
|
<div style="display:none;font-size:1px;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">${escapeHtml(resolvedSubject)}</div>
|
|
242
942
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" align="center" width="100%" style="${styleToString({ maxWidth: 600, width: "100%", backgroundColor: "#ffffff", border: `${cardBorderWidth}px solid #e3dccb`, borderRadius: cardBorderRadius, margin: "0 auto" })}">
|
|
243
|
-
<tbody><tr><td>${body}</td></tr></tbody>
|
|
943
|
+
<tbody><tr><td${fontFamily ? ` style="${styleToString(font)}"` : ""}>${body}</td></tr></tbody>
|
|
244
944
|
</table>
|
|
245
945
|
</body>
|
|
246
946
|
</html>`;
|
|
947
|
+
return applyTracking(html, tracking, context);
|
|
247
948
|
};
|
|
248
949
|
//# sourceMappingURL=html-render.js.map
|