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