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.
Files changed (49) hide show
  1. package/README.md +106 -2
  2. package/SKILL.md +217 -0
  3. package/dist/build.d.ts +52 -0
  4. package/dist/build.d.ts.map +1 -0
  5. package/dist/build.js +169 -0
  6. package/dist/build.js.map +1 -0
  7. package/dist/default-blocks.d.ts.map +1 -1
  8. package/dist/default-blocks.js +377 -5
  9. package/dist/default-blocks.js.map +1 -1
  10. package/dist/html-render.d.ts +4 -2
  11. package/dist/html-render.d.ts.map +1 -1
  12. package/dist/html-render.js +758 -57
  13. package/dist/html-render.js.map +1 -1
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/normalize.d.ts +10 -2
  19. package/dist/normalize.d.ts.map +1 -1
  20. package/dist/normalize.js +166 -38
  21. package/dist/normalize.js.map +1 -1
  22. package/dist/records.d.ts +13 -0
  23. package/dist/records.d.ts.map +1 -0
  24. package/dist/records.js +93 -0
  25. package/dist/records.js.map +1 -0
  26. package/dist/registry.d.ts +35 -0
  27. package/dist/registry.d.ts.map +1 -0
  28. package/dist/registry.js +31 -0
  29. package/dist/registry.js.map +1 -0
  30. package/dist/richtext.d.ts +6 -0
  31. package/dist/richtext.d.ts.map +1 -1
  32. package/dist/richtext.js +13 -0
  33. package/dist/richtext.js.map +1 -1
  34. package/dist/server.d.ts +4 -2
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +2 -1
  37. package/dist/server.js.map +1 -1
  38. package/dist/tracking.d.ts +12 -0
  39. package/dist/tracking.d.ts.map +1 -0
  40. package/dist/tracking.js +78 -0
  41. package/dist/tracking.js.map +1 -0
  42. package/dist/types.d.ts +407 -7
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/types.js +159 -0
  45. package/dist/types.js.map +1 -1
  46. package/docs/BLOCKS.md +530 -0
  47. package/docs/MCP.md +189 -0
  48. package/llms.txt +17 -0
  49. package/package.json +17 -4
@@ -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 { DEFAULT_PALETTE, DEFAULT_SETTINGS } from "./default-blocks.js";
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 py = "paddingY" in props ? (props.paddingY ?? 0) : 0;
42
- const px = "paddingX" in props ? (props.paddingX ?? 0) : 0;
43
- return { padding: `${py}px ${px}px` };
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) => `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td style="${styleToString(style)}">${content}</td></tr></tbody></table>`;
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
- ...blockPadding(props),
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
- ...blockPadding(props),
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
- ...blockPadding(props),
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
- ...blockPadding(props),
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 items = props.items
93
- .map((item) => `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom:8px;"><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>`)
94
- .join("");
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
- ...blockPadding(props),
185
+ ...blockSpacing(props),
97
186
  ...(props.backgroundColor
98
187
  ? { backgroundColor: props.backgroundColor }
99
188
  : {}),
100
- }, items);
189
+ }, content);
101
190
  };
102
191
  const renderButton = (props, context, palette) => section({
103
192
  textAlign: alignMap[props.align || "center"],
104
- ...blockPadding(props),
193
+ ...blockSpacing(props),
105
194
  ...(props.blockBackgroundColor
106
195
  ? { backgroundColor: props.blockBackgroundColor }
107
196
  : {}),
108
- }, `<a href="${escapeHtml(resolveVariables(props.href, context))}" target="_blank" style="${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))}</a>`);
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
- ...blockPadding(props),
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
- ...blockPadding(props),
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 cols = (props.columns || [])
149
- .map((col) => `<td width="50%" valign="top" style="width:50%;padding-right:8px;"><div style="${styleToString({ color: palette.INK, fontSize: 14, lineHeight: 1.5, margin: 0 })}">${renderRichText(resolveVariables(col.text, context))}</div></td>`)
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
- ...blockPadding(props),
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
- : blockPadding(props);
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
  }, "&nbsp;");
177
- const renderFooter = (props, context, palette) => {
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 renderBlock = (block, context, palette) => {
191
- switch (block.type) {
192
- case "header":
193
- return renderHeader(block.props, context, palette);
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 renderHero(block.props, context, palette);
196
- case "heading":
197
- return renderHeading(block.props, context, palette);
198
- case "text":
199
- return renderText(block.props, context, palette);
200
- case "list":
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}">&nbsp;</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
- return `<!DOCTYPE html>
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