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.
Files changed (45) hide show
  1. package/README.md +106 -2
  2. package/SKILL.md +189 -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 +357 -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 +694 -54
  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 +3 -1
  19. package/dist/normalize.d.ts.map +1 -1
  20. package/dist/normalize.js +120 -40
  21. package/dist/normalize.js.map +1 -1
  22. package/dist/records.d.ts +8 -0
  23. package/dist/records.d.ts.map +1 -0
  24. package/dist/records.js +81 -0
  25. package/dist/records.js.map +1 -0
  26. package/dist/registry.d.ts +29 -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/server.d.ts +4 -2
  31. package/dist/server.d.ts.map +1 -1
  32. package/dist/server.js +2 -1
  33. package/dist/server.js.map +1 -1
  34. package/dist/tracking.d.ts +12 -0
  35. package/dist/tracking.d.ts.map +1 -0
  36. package/dist/tracking.js +78 -0
  37. package/dist/tracking.js.map +1 -0
  38. package/dist/types.d.ts +354 -7
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/types.js +82 -0
  41. package/dist/types.js.map +1 -1
  42. package/docs/BLOCKS.md +467 -0
  43. package/docs/MCP.md +186 -0
  44. package/llms.txt +15 -0
  45. package/package.json +17 -4
@@ -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 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` };
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) => `<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td style="${styleToString(style)}">${content}</td></tr></tbody></table>`;
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
- ...blockPadding(props),
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
- ...blockPadding(props),
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
- ...blockPadding(props),
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
- ...blockPadding(props),
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 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("");
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
- ...blockPadding(props),
184
+ ...blockSpacing(props),
97
185
  ...(props.backgroundColor
98
186
  ? { backgroundColor: props.backgroundColor }
99
187
  : {}),
100
- }, items);
188
+ }, content);
101
189
  };
102
190
  const renderButton = (props, context, palette) => section({
103
191
  textAlign: alignMap[props.align || "center"],
104
- ...blockPadding(props),
192
+ ...blockSpacing(props),
105
193
  ...(props.blockBackgroundColor
106
194
  ? { backgroundColor: props.blockBackgroundColor }
107
195
  : {}),
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>`);
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
- ...blockPadding(props),
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
- ...blockPadding(props),
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 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 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
- ...blockPadding(props),
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
- : blockPadding(props);
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
  }, "&nbsp;");
177
- const renderFooter = (props, context, palette) => {
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 renderBlock = (block, context, palette) => {
191
- switch (block.type) {
192
- case "header":
193
- return renderHeader(block.props, context, palette);
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 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);
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}">&nbsp;</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
- return `<!DOCTYPE html>
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