@dev.smartpricing/message-composer-utils 3.1.2

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 (119) hide show
  1. package/dist/types/api/aiIntegration.js +7 -0
  2. package/dist/types/api/aiIntegration.js.map +1 -0
  3. package/dist/types/api/apiMessages.js +2 -0
  4. package/dist/types/api/apiMessages.js.map +1 -0
  5. package/dist/types/api/index.js +3 -0
  6. package/dist/types/api/index.js.map +1 -0
  7. package/dist/types/app.js +23 -0
  8. package/dist/types/app.js.map +1 -0
  9. package/dist/types/brand.js +31 -0
  10. package/dist/types/brand.js.map +1 -0
  11. package/dist/types/dynamic-values.js +5 -0
  12. package/dist/types/dynamic-values.js.map +1 -0
  13. package/dist/types/emailblocks.v1.defaults.js +83 -0
  14. package/dist/types/emailblocks.v1.defaults.js.map +1 -0
  15. package/dist/types/emailblocks.v1.js +339 -0
  16. package/dist/types/emailblocks.v1.js.map +1 -0
  17. package/dist/types/index.js +16 -0
  18. package/dist/types/index.js.map +1 -0
  19. package/dist/types/kafka-events.js +2 -0
  20. package/dist/types/kafka-events.js.map +1 -0
  21. package/dist/types/language.js +2 -0
  22. package/dist/types/language.js.map +1 -0
  23. package/dist/types/media.js +6 -0
  24. package/dist/types/media.js.map +1 -0
  25. package/dist/types/message.js +41 -0
  26. package/dist/types/message.js.map +1 -0
  27. package/dist/types/meta-template.js +2 -0
  28. package/dist/types/meta-template.js.map +1 -0
  29. package/dist/types/meta-webhook.js +2 -0
  30. package/dist/types/meta-webhook.js.map +1 -0
  31. package/dist/types/submission-job.js +2 -0
  32. package/dist/types/submission-job.js.map +1 -0
  33. package/dist/types/tag.js +2 -0
  34. package/dist/types/tag.js.map +1 -0
  35. package/dist/types/utils.js +2 -0
  36. package/dist/types/utils.js.map +1 -0
  37. package/dist/types/whatsappblocks.v1.js +24 -0
  38. package/dist/types/whatsappblocks.v1.js.map +1 -0
  39. package/dist/utils/array.js +26 -0
  40. package/dist/utils/array.js.map +1 -0
  41. package/dist/utils/async.js +22 -0
  42. package/dist/utils/async.js.map +1 -0
  43. package/dist/utils/date.js +37 -0
  44. package/dist/utils/date.js.map +1 -0
  45. package/dist/utils/index.js +34 -0
  46. package/dist/utils/index.js.map +1 -0
  47. package/dist/utils/intl.js +37 -0
  48. package/dist/utils/intl.js.map +1 -0
  49. package/dist/utils/meta-status.js +21 -0
  50. package/dist/utils/meta-status.js.map +1 -0
  51. package/dist/utils/number.js +24 -0
  52. package/dist/utils/number.js.map +1 -0
  53. package/dist/utils/object.js +87 -0
  54. package/dist/utils/object.js.map +1 -0
  55. package/dist/utils/string.js +22 -0
  56. package/dist/utils/string.js.map +1 -0
  57. package/dist/utils/templates/brand-cascade.js +116 -0
  58. package/dist/utils/templates/brand-cascade.js.map +1 -0
  59. package/dist/utils/templates/email-compiler.js +126 -0
  60. package/dist/utils/templates/email-compiler.js.map +1 -0
  61. package/dist/utils/templates/email-mjml.js +444 -0
  62. package/dist/utils/templates/email-mjml.js.map +1 -0
  63. package/dist/utils/templates/email.js +580 -0
  64. package/dist/utils/templates/email.js.map +1 -0
  65. package/dist/utils/templates/html-sanitizer.js +95 -0
  66. package/dist/utils/templates/html-sanitizer.js.map +1 -0
  67. package/dist/utils/templates/messages.js +33 -0
  68. package/dist/utils/templates/messages.js.map +1 -0
  69. package/dist/utils/templates/meta.js +246 -0
  70. package/dist/utils/templates/meta.js.map +1 -0
  71. package/dist/utils/templates/tiptap.js +167 -0
  72. package/dist/utils/templates/tiptap.js.map +1 -0
  73. package/dist/utils/templates/whatsapp-compiler.js +168 -0
  74. package/dist/utils/templates/whatsapp-compiler.js.map +1 -0
  75. package/dist/utils/templates/whatsapp.js +329 -0
  76. package/dist/utils/templates/whatsapp.js.map +1 -0
  77. package/dist/utils/validations.js +39 -0
  78. package/dist/utils/validations.js.map +1 -0
  79. package/package.json +43 -0
  80. package/src/types/api/aiIntegration.ts +59 -0
  81. package/src/types/api/apiMessages.ts +22 -0
  82. package/src/types/api/index.ts +2 -0
  83. package/src/types/app.ts +76 -0
  84. package/src/types/brand.ts +80 -0
  85. package/src/types/dynamic-values.ts +47 -0
  86. package/src/types/emailblocks.v1.defaults.ts +123 -0
  87. package/src/types/emailblocks.v1.ts +655 -0
  88. package/src/types/index.ts +15 -0
  89. package/src/types/kafka-events.ts +46 -0
  90. package/src/types/language.ts +9 -0
  91. package/src/types/media.ts +26 -0
  92. package/src/types/message.ts +635 -0
  93. package/src/types/meta-template.ts +50 -0
  94. package/src/types/meta-webhook.ts +32 -0
  95. package/src/types/submission-job.ts +30 -0
  96. package/src/types/tag.ts +8 -0
  97. package/src/types/utils.ts +14 -0
  98. package/src/types/whatsappblocks.v1.ts +102 -0
  99. package/src/utils/array.ts +33 -0
  100. package/src/utils/async.ts +30 -0
  101. package/src/utils/date.ts +39 -0
  102. package/src/utils/index.ts +44 -0
  103. package/src/utils/intl.ts +51 -0
  104. package/src/utils/meta-status.ts +24 -0
  105. package/src/utils/number.ts +36 -0
  106. package/src/utils/object.ts +101 -0
  107. package/src/utils/string.ts +24 -0
  108. package/src/utils/templates/brand-cascade.ts +156 -0
  109. package/src/utils/templates/email-compiler.ts +155 -0
  110. package/src/utils/templates/email-mjml.ts +602 -0
  111. package/src/utils/templates/email.ts +713 -0
  112. package/src/utils/templates/html-sanitizer.ts +121 -0
  113. package/src/utils/templates/messages.ts +44 -0
  114. package/src/utils/templates/meta.ts +336 -0
  115. package/src/utils/templates/tiptap.ts +247 -0
  116. package/src/utils/templates/whatsapp-compiler.ts +212 -0
  117. package/src/utils/templates/whatsapp.ts +408 -0
  118. package/src/utils/validations.ts +49 -0
  119. package/tsconfig.json +10 -0
@@ -0,0 +1,713 @@
1
+ import {
2
+ type PostEntity,
3
+ type EmailMessage,
4
+ type EmailGlobalSettings,
5
+ type EmailBlock,
6
+ type GridBlock,
7
+ type GridColumn,
8
+ type GridAllowedBlock,
9
+ type TranslatableBlockType,
10
+ type SocialPlatform,
11
+ TRANSLATABLE_BLOCK_TYPES,
12
+ HTML_MAX_LENGTH,
13
+ TranslatableBlock,
14
+ emptyFooterBlock,
15
+ emptyDividerBlock,
16
+ emptyUnsubscribeLinkBlock,
17
+ emptyHeaderBlock,
18
+ } from "../../types/index.js";
19
+ import type { BrandCascadeData } from "./brand-cascade.js";
20
+ import { generateHTML } from "@tiptap/html";
21
+ import { generateText } from "@tiptap/core";
22
+ import type { JSONContent } from "@tiptap/core";
23
+ import { getTipTapExtensions, isTiptapEmpty } from "./tiptap.js";
24
+ import { isValidUrl, isValidPhoneNumber, isEmail } from "../validations.js";
25
+
26
+ /**
27
+ * Creates an empty email message template
28
+ */
29
+ export function getEmailEmpty(languageId: string): PostEntity<EmailMessage> {
30
+ const defaultSettings: EmailGlobalSettings = {
31
+ contentWidth: 600,
32
+ verticalSpacing: 0,
33
+ horizontalSpacing: 0,
34
+ };
35
+
36
+ return {
37
+ user_id: "",
38
+ status: "draft",
39
+ render_type: "email_v1",
40
+ body: {
41
+ subject: "",
42
+ blocks: [
43
+ emptyHeaderBlock(),
44
+ emptyFooterBlock(),
45
+ emptyDividerBlock(),
46
+ emptyUnsubscribeLinkBlock(languageId),
47
+ ],
48
+ settings: { ...defaultSettings },
49
+ },
50
+ metadata: {},
51
+ language_id: languageId,
52
+ message_group_id: "",
53
+ };
54
+ }
55
+
56
+ export interface EmailValidationResult {
57
+ valid: boolean;
58
+ errors: string[];
59
+ }
60
+
61
+ /**
62
+ * Validates if an email message is valid
63
+ * Checks:
64
+ * - Subject required and not empty
65
+ * - At least one non-empty block exists
66
+ * - Block-specific constraints (dimensions, content)
67
+ */
68
+ export function isEmailValid(
69
+ message: PostEntity<EmailMessage> | EmailMessage,
70
+ brand?: BrandCascadeData | null,
71
+ ): EmailValidationResult {
72
+ const errors: string[] = [];
73
+
74
+ if (message.render_type !== "email_v1") {
75
+ return {
76
+ valid: false,
77
+ errors: ["editor.validation.error.invalidRenderType"],
78
+ };
79
+ }
80
+
81
+ // Validate subject
82
+ if (!message.body.subject || message.body.subject.trim() === "") {
83
+ errors.push("editor.validation.error.subjectRequired");
84
+ }
85
+
86
+ // Validate that at least one content block exists (text or image)
87
+ const hasContent = hasContentBlock(message.body.blocks);
88
+
89
+ if (!hasContent) {
90
+ errors.push("editor.validation.error.atLeastOneBlockRequired");
91
+ }
92
+
93
+ // Validate unsubscribe block exists
94
+ const hasUnsubscribe = message.body.blocks.some(
95
+ (block) => block.type === "unsubscribe_link",
96
+ );
97
+ if (!hasUnsubscribe) {
98
+ errors.push("editor.validation.error.unsubscribeBlockMissing");
99
+ }
100
+
101
+ // Validate block constraints
102
+ for (const block of message.body.blocks) {
103
+ validateEmailBlock(block, errors, brand);
104
+ }
105
+
106
+ return {
107
+ valid: errors.length === 0,
108
+ errors,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Check if blocks array has at least one content block (text or image with src)
114
+ */
115
+ function hasContentBlock(blocks: EmailBlock[]): boolean {
116
+ for (const block of blocks) {
117
+ // Check translatable text blocks
118
+ if (
119
+ block.type === "heading" ||
120
+ block.type === "paragraph" ||
121
+ block.type === "button" ||
122
+ block.type === "discount" ||
123
+ block.type === "footer" ||
124
+ block.type === "raw_html" ||
125
+ block.type === "unsubscribe_link"
126
+ ) {
127
+ if (!isEmailBlockEmpty(block)) return true;
128
+ }
129
+ // Check image blocks with valid src
130
+ if (block.type === "image") {
131
+ if (block.settings.src && block.settings.src.trim() !== "") return true;
132
+ }
133
+ // Recursively check grid columns
134
+ if (block.type === "grid") {
135
+ for (const column of block.settings.columnData) {
136
+ if (hasContentBlock(column.blocks)) return true;
137
+ }
138
+ }
139
+ }
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * Validates individual email blocks
145
+ */
146
+ function validateEmailBlock(
147
+ block: EmailBlock,
148
+ errors: string[],
149
+ brand?: BrandCascadeData | null,
150
+ ): void {
151
+ switch (block.type) {
152
+ case "image":
153
+ if (!block.settings.src || block.settings.src.trim() === "") {
154
+ errors.push("editor.validation.error.imageSourceRequired");
155
+ }
156
+ break;
157
+
158
+ case "header":
159
+ if (
160
+ (!block.settings.src || block.settings.src.trim() === "") &&
161
+ !brand?.logo_url
162
+ ) {
163
+ errors.push("editor.validation.error.headerLogoRequired");
164
+ }
165
+ break;
166
+
167
+ case "button":
168
+ if (isTiptapEmpty(block.settings.content, "email_v1")) {
169
+ errors.push("editor.validation.error.buttonTextRequired");
170
+ }
171
+ if (!block.settings.url || block.settings.url.trim() === "") {
172
+ errors.push("editor.validation.error.urlRequired");
173
+ } else {
174
+ const url = block.settings.url.trim();
175
+ switch (block.settings.linkType) {
176
+ case "web":
177
+ if (!isValidUrl(url)) {
178
+ errors.push("editor.validation.error.urlInvalid");
179
+ }
180
+ break;
181
+ case "email":
182
+ if (!isEmail(url)) {
183
+ errors.push("editor.validation.error.emailInvalid");
184
+ }
185
+ break;
186
+ case "phone":
187
+ if (!isValidPhoneNumber(url)) {
188
+ errors.push("editor.validation.error.phoneInvalid");
189
+ }
190
+ break;
191
+ }
192
+ }
193
+ break;
194
+
195
+ case "unsubscribe_link": {
196
+ // Content must contain {{unsubscribe}} dynamic value
197
+ const html = generateEmailHtml(block.settings.content);
198
+ if (!html.includes("{{unsubscribe}}")) {
199
+ errors.push("editor.validation.error.unsubscribeLinkRequired");
200
+ }
201
+ break;
202
+ }
203
+
204
+ case "socials": {
205
+ const entries = Object.entries(block.settings.socials) as [
206
+ SocialPlatform,
207
+ { enabled: boolean; url: string },
208
+ ][];
209
+ const enabled = entries.filter(([, s]) => s.enabled);
210
+ if (enabled.length === 0) {
211
+ errors.push("editor.validation.error.socialsNoEnabled");
212
+ } else {
213
+ const brandSocials = brand?.default_socials;
214
+ for (const [platform, social] of enabled) {
215
+ const hasBlockUrl = social.url && social.url.trim() !== "";
216
+ const hasBrandUrl = !!brandSocials?.[platform];
217
+ if (!hasBlockUrl && !hasBrandUrl) {
218
+ errors.push("editor.validation.error.socialsUrlRequired");
219
+ break;
220
+ }
221
+ }
222
+ }
223
+ break;
224
+ }
225
+
226
+ case "raw_html":
227
+ if (block.settings.html.length > HTML_MAX_LENGTH) {
228
+ errors.push("editor.validation.error.htmlMaxLength");
229
+ }
230
+ break;
231
+
232
+ case "top_products":
233
+ if (!block.settings.storeId || block.settings.storeId.trim() === "") {
234
+ errors.push("editor.validation.error.topProductsStoreIdRequired");
235
+ }
236
+ break;
237
+
238
+ case "grid":
239
+ // Each column must have at least one valid block
240
+ for (const column of block.settings.columnData) {
241
+ if (!hasContentBlock(column.blocks)) {
242
+ errors.push("editor.validation.error.gridColumnEmpty");
243
+ }
244
+ // Recursively validate nested blocks
245
+ for (const nestedBlock of column.blocks) {
246
+ validateEmailBlock(nestedBlock, errors, brand);
247
+ }
248
+ }
249
+ break;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Extract translatable blocks from email blocks array (including nested grid blocks)
255
+ */
256
+ export function extractTranslatableBlocks(blocks: EmailBlock[]): EmailBlock[] {
257
+ const translatable: EmailBlock[] = [];
258
+
259
+ for (const block of blocks) {
260
+ if (
261
+ block.type === "heading" ||
262
+ block.type === "paragraph" ||
263
+ block.type === "button" ||
264
+ block.type === "discount" ||
265
+ block.type === "footer" ||
266
+ block.type === "raw_html" ||
267
+ block.type === "unsubscribe_link"
268
+ ) {
269
+ translatable.push(block);
270
+ } else if (block.type === "grid") {
271
+ // Recursively extract from grid columns
272
+ for (const column of block.settings.columnData) {
273
+ const nestedTranslatable = extractTranslatableBlocks(column.blocks);
274
+ translatable.push(...nestedTranslatable);
275
+ }
276
+ }
277
+ }
278
+
279
+ return translatable;
280
+ }
281
+
282
+ /**
283
+ * Check if email block content is empty
284
+ */
285
+ export function isEmailBlockEmpty(block: EmailBlock): boolean {
286
+ switch (block.type) {
287
+ case "heading":
288
+ case "paragraph":
289
+ case "button":
290
+ case "discount":
291
+ case "footer":
292
+ case "unsubscribe_link":
293
+ return (
294
+ Boolean(block.settings.content) &&
295
+ isTiptapEmpty(block.settings.content, "email_v1")
296
+ );
297
+ case "raw_html":
298
+ return !block.settings.html || block.settings.html.trim() === "";
299
+ case "image":
300
+ case "header":
301
+ return !block.settings.src;
302
+ default:
303
+ return false;
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Check if email block content is empty
309
+ */
310
+ export function isEmailTranslatableBlockEmpty(block: EmailBlock): boolean {
311
+ switch (block.type) {
312
+ case "heading":
313
+ case "paragraph":
314
+ case "button":
315
+ case "discount":
316
+ case "footer":
317
+ case "unsubscribe_link":
318
+ return (
319
+ Boolean(block.settings.content) &&
320
+ isTiptapEmpty(block.settings.content, "email_v1")
321
+ );
322
+ case "raw_html":
323
+ return !block.settings.html || block.settings.html.trim() === "";
324
+ default:
325
+ return true;
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Calculate translation percentage for email message
331
+ */
332
+ export function calculateEmailTranslationPercentage(
333
+ target: EmailMessage | PostEntity<EmailMessage>,
334
+ base: EmailMessage | PostEntity<EmailMessage>,
335
+ ): number {
336
+ if (target.render_type !== "email_v1" || base.render_type !== "email_v1") {
337
+ return 0;
338
+ }
339
+
340
+ const baseTranslatableBlocks = extractTranslatableBlocks(base.body.blocks);
341
+ const targetTranslatableBlocks = extractTranslatableBlocks(
342
+ target.body.blocks,
343
+ );
344
+
345
+ // Build ID-based map of target blocks for matching
346
+ const targetBlocksMap = new Map(
347
+ targetTranslatableBlocks.map((block) => [block.id, block]),
348
+ );
349
+
350
+ let totalFields = baseTranslatableBlocks.length;
351
+ let completedFields = 0;
352
+
353
+ // Count subject line
354
+ if (base.body.subject && base.body.subject.trim() !== "") {
355
+ totalFields++;
356
+ if (target.body.subject && target.body.subject.trim() !== "") {
357
+ completedFields++;
358
+ }
359
+ }
360
+
361
+ // Count translated blocks by ID
362
+ for (const baseBlock of baseTranslatableBlocks) {
363
+ const targetBlock = targetBlocksMap.get(baseBlock.id);
364
+
365
+ if (!targetBlock) continue;
366
+
367
+ // Check if base has content
368
+ if (!isEmailBlockEmpty(baseBlock)) {
369
+ // Check if target also has content
370
+ if (!isEmailBlockEmpty(targetBlock)) {
371
+ completedFields++;
372
+ }
373
+ } else {
374
+ // If base is empty, don't count it
375
+ totalFields--;
376
+ }
377
+ }
378
+
379
+ if (totalFields === 0) return 100;
380
+ return Math.round((completedFields / totalFields) * 100);
381
+ }
382
+
383
+ /**
384
+ * Duplicate blocks for new language (preserve block IDs, clear translatable content)
385
+ */
386
+ export function duplicateBlocksForNewLanguage(
387
+ blocks: EmailBlock[],
388
+ ): EmailBlock[] {
389
+ return blocks.map((block) => {
390
+ const newBlock = { ...block };
391
+
392
+ switch (block.type) {
393
+ case "heading":
394
+ case "paragraph":
395
+ case "button":
396
+ case "discount":
397
+ case "footer":
398
+ case "unsubscribe_link":
399
+ // Clear translatable content
400
+ return {
401
+ ...newBlock,
402
+ settings: {
403
+ ...block.settings,
404
+ content: undefined,
405
+ },
406
+ } as EmailBlock;
407
+
408
+ case "raw_html":
409
+ // Copy HTML as-is (manual customization needed)
410
+ return {
411
+ ...newBlock,
412
+ settings: { ...block.settings, html: "" },
413
+ } as EmailBlock;
414
+
415
+ case "grid":
416
+ // Recursively duplicate nested blocks
417
+ return {
418
+ ...newBlock,
419
+ settings: {
420
+ ...block.settings,
421
+ columnData: block.settings.columnData.map((column) => ({
422
+ id: column.id,
423
+ blocks: duplicateBlocksForNewLanguage(column.blocks),
424
+ })),
425
+ },
426
+ } as GridBlock;
427
+
428
+ default:
429
+ // Non-translatable blocks (spacer, divider, image, header) - copy as-is
430
+ return {
431
+ ...newBlock,
432
+ settings: { ...block.settings },
433
+ } as EmailBlock;
434
+ }
435
+ });
436
+ }
437
+
438
+ /**
439
+ * Helper: Check if block type has translatable content
440
+ */
441
+ function hasTranslatableContent(block: EmailBlock): block is TranslatableBlock {
442
+ return TRANSLATABLE_BLOCK_TYPES.includes(block.type as TranslatableBlockType);
443
+ }
444
+
445
+ /**
446
+ * Helper: Create empty translation version of a block
447
+ */
448
+ function createEmptyTranslationBlock(baseBlock: EmailBlock): EmailBlock {
449
+ const newBlock = { ...baseBlock };
450
+
451
+ switch (baseBlock.type) {
452
+ case "heading":
453
+ case "paragraph":
454
+ case "button":
455
+ case "discount":
456
+ case "footer":
457
+ case "unsubscribe_link":
458
+ return {
459
+ ...newBlock,
460
+ settings: {
461
+ ...baseBlock.settings,
462
+ content: undefined,
463
+ },
464
+ } as EmailBlock;
465
+
466
+ case "grid":
467
+ return {
468
+ ...newBlock,
469
+ settings: {
470
+ ...baseBlock.settings,
471
+ columnData: baseBlock.settings.columnData.map((column) => ({
472
+ id: column.id,
473
+ blocks: column.blocks.map(createEmptyTranslationBlock),
474
+ })),
475
+ },
476
+ } as GridBlock;
477
+
478
+ default:
479
+ return {
480
+ ...newBlock,
481
+ settings: { ...baseBlock.settings },
482
+ } as EmailBlock;
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Build ID-based map of all target blocks (including nested grid blocks)
488
+ */
489
+ function buildTargetBlockMap(blocks: EmailBlock[]): Map<string, EmailBlock> {
490
+ const map = new Map<string, EmailBlock>();
491
+
492
+ for (const block of blocks) {
493
+ map.set(block.id, block);
494
+ if (block.type === "grid") {
495
+ for (const column of block.settings.columnData) {
496
+ const nestedMap = buildTargetBlockMap(column.blocks);
497
+ for (const [id, nestedBlock] of nestedMap) {
498
+ map.set(id, nestedBlock);
499
+ }
500
+ }
501
+ }
502
+ }
503
+
504
+ return map;
505
+ }
506
+
507
+ /**
508
+ * Sync grid columns by ID
509
+ */
510
+ function syncGridColumnsForLayout(
511
+ baseColumns: GridColumn[],
512
+ targetColumns: GridColumn[],
513
+ ): GridColumn[] {
514
+ const targetMap = new Map(targetColumns.map((col) => [col.id, col]));
515
+
516
+ return baseColumns.map((baseCol) => {
517
+ const existing = targetMap.get(baseCol.id);
518
+
519
+ if (existing) {
520
+ return {
521
+ id: baseCol.id,
522
+ blocks: syncBlocksRecursiveForLayout(
523
+ baseCol.blocks,
524
+ existing.blocks,
525
+ ) as GridAllowedBlock[],
526
+ };
527
+ } else {
528
+ return {
529
+ id: baseCol.id,
530
+ blocks: baseCol.blocks.map(
531
+ createEmptyTranslationBlock,
532
+ ) as GridAllowedBlock[],
533
+ };
534
+ }
535
+ });
536
+ }
537
+
538
+ /**
539
+ * Recursively sync blocks (for nested grids)
540
+ */
541
+ function syncBlocksRecursiveForLayout(
542
+ baseBlocks: EmailBlock[],
543
+ targetBlocks: EmailBlock[],
544
+ ): EmailBlock[] {
545
+ const targetMap = new Map(targetBlocks.map((b) => [b.id, b]));
546
+
547
+ return baseBlocks.map((baseBlock) => {
548
+ const existing = targetMap.get(baseBlock.id);
549
+
550
+ if (existing) {
551
+ const synced = { ...existing };
552
+
553
+ if (baseBlock.type === "grid" && existing.type === "grid") {
554
+ synced.settings = {
555
+ ...baseBlock.settings,
556
+ columnData: syncGridColumnsForLayout(
557
+ baseBlock.settings.columnData,
558
+ existing.settings.columnData,
559
+ ),
560
+ };
561
+ } else if (
562
+ hasTranslatableContent(baseBlock) &&
563
+ hasTranslatableContent(existing)
564
+ ) {
565
+ synced.settings = {
566
+ ...baseBlock.settings,
567
+ content: existing.settings.content,
568
+ };
569
+ } else if (baseBlock.type === "image" && existing.type === "image") {
570
+ synced.settings = {
571
+ ...baseBlock.settings,
572
+ src: existing.settings.src,
573
+ href: existing.settings.href,
574
+ };
575
+ } else {
576
+ synced.settings = { ...baseBlock.settings };
577
+ }
578
+
579
+ return synced as EmailBlock;
580
+ } else {
581
+ return createEmptyTranslationBlock(baseBlock);
582
+ }
583
+ });
584
+ }
585
+
586
+ /**
587
+ * Sync single block using target blocks map
588
+ */
589
+ function syncSingleBlock(
590
+ baseBlock: EmailBlock,
591
+ targetBlocksMap: Map<string, EmailBlock>,
592
+ ): EmailBlock {
593
+ const existing = targetBlocksMap.get(baseBlock.id);
594
+
595
+ if (existing) {
596
+ // Preserve translatable content, sync structure
597
+ const synced = { ...existing };
598
+
599
+ switch (baseBlock.type) {
600
+ case "heading":
601
+ case "paragraph":
602
+ case "button":
603
+ case "discount":
604
+ case "footer":
605
+ case "unsubscribe_link":
606
+ synced.settings = {
607
+ ...baseBlock.settings,
608
+ content: (existing as TranslatableBlock).settings.content, // PRESERVE
609
+ };
610
+ break;
611
+
612
+ case "raw_html":
613
+ // Keep target's HTML (manual customization)
614
+ synced.settings = { ...existing.settings };
615
+ break;
616
+
617
+ case "image":
618
+ if (existing.type === "image") {
619
+ synced.settings = {
620
+ ...baseBlock.settings,
621
+ src: existing.settings.src,
622
+ href: existing.settings.href,
623
+ };
624
+ }
625
+ break;
626
+
627
+ case "header":
628
+ if (existing.type === "header") {
629
+ synced.settings = {
630
+ ...baseBlock.settings,
631
+ src: existing.settings.src,
632
+ alt: existing.settings.alt,
633
+ };
634
+ }
635
+ break;
636
+
637
+ case "grid":
638
+ if (existing.type === "grid") {
639
+ synced.settings = {
640
+ ...baseBlock.settings,
641
+ columnData: syncGridColumnsForLayout(
642
+ baseBlock.settings.columnData,
643
+ existing.settings.columnData,
644
+ ),
645
+ };
646
+ }
647
+ break;
648
+
649
+ default:
650
+ // Non-translatable blocks (spacer, divider)
651
+ synced.settings = { ...baseBlock.settings };
652
+ }
653
+
654
+ return synced as EmailBlock;
655
+ } else {
656
+ // Block doesn't exist in target - create empty translation
657
+ return createEmptyTranslationBlock(baseBlock);
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Sync layout from base language to target language
663
+ * - Preserves translatable content in target
664
+ * - Matches blocks by ID
665
+ * - Handles: add blocks, remove blocks (orphans), reorder blocks
666
+ * - Recursively handles grid columns and nested blocks
667
+ */
668
+ export function syncLayoutFromBase(
669
+ baseBlocks: EmailBlock[],
670
+ targetBlocks: EmailBlock[],
671
+ ): EmailBlock[] {
672
+ // Build ID map of all target blocks (including nested)
673
+ const targetBlocksMap = buildTargetBlockMap(targetBlocks);
674
+
675
+ // Main sync: rebuild target array following base order
676
+ return baseBlocks.map((block) => syncSingleBlock(block, targetBlocksMap));
677
+ }
678
+
679
+ export function generateEmailTextPreview(
680
+ message: PostEntity<EmailMessage> | undefined,
681
+ ) {
682
+ if (!message?.body.blocks) return "";
683
+ const content = message.body.blocks
684
+ .filter(
685
+ (block) =>
686
+ block.type === "paragraph" ||
687
+ block.type === "heading" ||
688
+ block.type === "button" ||
689
+ block.type === "discount" ||
690
+ block.type === "footer" ||
691
+ block.type === "unsubscribe_link",
692
+ )
693
+ .at(0);
694
+ if (content) {
695
+ return generateEmailText(content.settings.content);
696
+ }
697
+ const html = message.body.blocks
698
+ .filter((block) => block.type === "raw_html")
699
+ .at(0);
700
+ if (html) {
701
+ return html.settings.html;
702
+ }
703
+ return "";
704
+ }
705
+
706
+ export function generateEmailText(content: JSONContent | undefined) {
707
+ if (!content) return "";
708
+ return generateText(content, getTipTapExtensions({ preset: "email" }));
709
+ }
710
+ export function generateEmailHtml(content: JSONContent | undefined) {
711
+ if (!content) return "";
712
+ return generateHTML(content, getTipTapExtensions({ preset: "email" }));
713
+ }