@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.
- package/dist/types/api/aiIntegration.js +7 -0
- package/dist/types/api/aiIntegration.js.map +1 -0
- package/dist/types/api/apiMessages.js +2 -0
- package/dist/types/api/apiMessages.js.map +1 -0
- package/dist/types/api/index.js +3 -0
- package/dist/types/api/index.js.map +1 -0
- package/dist/types/app.js +23 -0
- package/dist/types/app.js.map +1 -0
- package/dist/types/brand.js +31 -0
- package/dist/types/brand.js.map +1 -0
- package/dist/types/dynamic-values.js +5 -0
- package/dist/types/dynamic-values.js.map +1 -0
- package/dist/types/emailblocks.v1.defaults.js +83 -0
- package/dist/types/emailblocks.v1.defaults.js.map +1 -0
- package/dist/types/emailblocks.v1.js +339 -0
- package/dist/types/emailblocks.v1.js.map +1 -0
- package/dist/types/index.js +16 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/kafka-events.js +2 -0
- package/dist/types/kafka-events.js.map +1 -0
- package/dist/types/language.js +2 -0
- package/dist/types/language.js.map +1 -0
- package/dist/types/media.js +6 -0
- package/dist/types/media.js.map +1 -0
- package/dist/types/message.js +41 -0
- package/dist/types/message.js.map +1 -0
- package/dist/types/meta-template.js +2 -0
- package/dist/types/meta-template.js.map +1 -0
- package/dist/types/meta-webhook.js +2 -0
- package/dist/types/meta-webhook.js.map +1 -0
- package/dist/types/submission-job.js +2 -0
- package/dist/types/submission-job.js.map +1 -0
- package/dist/types/tag.js +2 -0
- package/dist/types/tag.js.map +1 -0
- package/dist/types/utils.js +2 -0
- package/dist/types/utils.js.map +1 -0
- package/dist/types/whatsappblocks.v1.js +24 -0
- package/dist/types/whatsappblocks.v1.js.map +1 -0
- package/dist/utils/array.js +26 -0
- package/dist/utils/array.js.map +1 -0
- package/dist/utils/async.js +22 -0
- package/dist/utils/async.js.map +1 -0
- package/dist/utils/date.js +37 -0
- package/dist/utils/date.js.map +1 -0
- package/dist/utils/index.js +34 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/intl.js +37 -0
- package/dist/utils/intl.js.map +1 -0
- package/dist/utils/meta-status.js +21 -0
- package/dist/utils/meta-status.js.map +1 -0
- package/dist/utils/number.js +24 -0
- package/dist/utils/number.js.map +1 -0
- package/dist/utils/object.js +87 -0
- package/dist/utils/object.js.map +1 -0
- package/dist/utils/string.js +22 -0
- package/dist/utils/string.js.map +1 -0
- package/dist/utils/templates/brand-cascade.js +116 -0
- package/dist/utils/templates/brand-cascade.js.map +1 -0
- package/dist/utils/templates/email-compiler.js +126 -0
- package/dist/utils/templates/email-compiler.js.map +1 -0
- package/dist/utils/templates/email-mjml.js +444 -0
- package/dist/utils/templates/email-mjml.js.map +1 -0
- package/dist/utils/templates/email.js +580 -0
- package/dist/utils/templates/email.js.map +1 -0
- package/dist/utils/templates/html-sanitizer.js +95 -0
- package/dist/utils/templates/html-sanitizer.js.map +1 -0
- package/dist/utils/templates/messages.js +33 -0
- package/dist/utils/templates/messages.js.map +1 -0
- package/dist/utils/templates/meta.js +246 -0
- package/dist/utils/templates/meta.js.map +1 -0
- package/dist/utils/templates/tiptap.js +167 -0
- package/dist/utils/templates/tiptap.js.map +1 -0
- package/dist/utils/templates/whatsapp-compiler.js +168 -0
- package/dist/utils/templates/whatsapp-compiler.js.map +1 -0
- package/dist/utils/templates/whatsapp.js +329 -0
- package/dist/utils/templates/whatsapp.js.map +1 -0
- package/dist/utils/validations.js +39 -0
- package/dist/utils/validations.js.map +1 -0
- package/package.json +43 -0
- package/src/types/api/aiIntegration.ts +59 -0
- package/src/types/api/apiMessages.ts +22 -0
- package/src/types/api/index.ts +2 -0
- package/src/types/app.ts +76 -0
- package/src/types/brand.ts +80 -0
- package/src/types/dynamic-values.ts +47 -0
- package/src/types/emailblocks.v1.defaults.ts +123 -0
- package/src/types/emailblocks.v1.ts +655 -0
- package/src/types/index.ts +15 -0
- package/src/types/kafka-events.ts +46 -0
- package/src/types/language.ts +9 -0
- package/src/types/media.ts +26 -0
- package/src/types/message.ts +635 -0
- package/src/types/meta-template.ts +50 -0
- package/src/types/meta-webhook.ts +32 -0
- package/src/types/submission-job.ts +30 -0
- package/src/types/tag.ts +8 -0
- package/src/types/utils.ts +14 -0
- package/src/types/whatsappblocks.v1.ts +102 -0
- package/src/utils/array.ts +33 -0
- package/src/utils/async.ts +30 -0
- package/src/utils/date.ts +39 -0
- package/src/utils/index.ts +44 -0
- package/src/utils/intl.ts +51 -0
- package/src/utils/meta-status.ts +24 -0
- package/src/utils/number.ts +36 -0
- package/src/utils/object.ts +101 -0
- package/src/utils/string.ts +24 -0
- package/src/utils/templates/brand-cascade.ts +156 -0
- package/src/utils/templates/email-compiler.ts +155 -0
- package/src/utils/templates/email-mjml.ts +602 -0
- package/src/utils/templates/email.ts +713 -0
- package/src/utils/templates/html-sanitizer.ts +121 -0
- package/src/utils/templates/messages.ts +44 -0
- package/src/utils/templates/meta.ts +336 -0
- package/src/utils/templates/tiptap.ts +247 -0
- package/src/utils/templates/whatsapp-compiler.ts +212 -0
- package/src/utils/templates/whatsapp.ts +408 -0
- package/src/utils/validations.ts +49 -0
- 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
|
+
}
|