@jhits/plugin-newsletter 0.0.9 → 0.0.11
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/package.json +3 -2
- package/src/api/email-utils.ts +165 -0
- package/src/api/handler.ts +28 -0
- package/src/api/handlers/index.ts +44 -0
- package/src/api/handlers/newsletters.ts +332 -0
- package/src/api/handlers/send-newsletter.ts +288 -0
- package/src/api/handlers/settings.ts +403 -0
- package/src/api/handlers/subscribers.ts +152 -0
- package/src/api/handlers/upload.ts +47 -0
- package/src/api/handlers/welcome-email.ts +210 -0
- package/src/api/router.ts +166 -0
- package/src/index.server.ts +12 -0
- package/src/index.tsx +353 -0
- package/src/index.tsx.patch +98 -0
- package/src/init.tsx +72 -0
- package/src/lib/blocks/BlockRenderer.tsx +125 -0
- package/src/lib/email/EmailRenderer.tsx +420 -0
- package/src/lib/email/index.ts +6 -0
- package/src/lib/i18n.ts +82 -0
- package/src/lib/mappers/apiMapper.ts +57 -0
- package/src/lib/utils/blockHelpers.ts +71 -0
- package/src/lib/utils/slugify.ts +43 -0
- package/src/registry/BlockRegistry.ts +53 -0
- package/src/registry/index.ts +5 -0
- package/src/state/EditorContext.tsx +278 -0
- package/src/state/index.ts +10 -0
- package/src/state/reducer.ts +561 -0
- package/src/state/types.ts +154 -0
- package/src/types/block.ts +275 -0
- package/src/types/newsletter.ts +152 -0
- package/src/types/registry.ts +14 -0
- package/src/views/CanvasEditor/BlockWrapper.tsx +143 -0
- package/src/views/CanvasEditor/CanvasEditorView.tsx +343 -0
- package/src/views/CanvasEditor/EditorBody.tsx +95 -0
- package/src/views/CanvasEditor/EditorHeader.tsx +255 -0
- package/src/views/CanvasEditor/components/CustomBlockItem.tsx +83 -0
- package/src/views/CanvasEditor/components/EditorCanvas.tsx +674 -0
- package/src/views/CanvasEditor/components/EditorLibrary.tsx +120 -0
- package/src/views/CanvasEditor/components/EditorSidebar.tsx +139 -0
- package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
- package/src/views/CanvasEditor/components/LibraryItem.tsx +71 -0
- package/src/views/CanvasEditor/components/SlashCommandDetector.tsx +196 -0
- package/src/views/CanvasEditor/components/SlashCommandMenu.tsx +131 -0
- package/src/views/CanvasEditor/components/index.ts +16 -0
- package/src/views/CanvasEditor/hooks/index.ts +7 -0
- package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +136 -0
- package/src/views/CanvasEditor/hooks/useNewsletterLoader.ts +73 -0
- package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +54 -0
- package/src/views/CanvasEditor/hooks/useSlashCommand.ts +106 -0
- package/src/views/CanvasEditor/index.ts +12 -0
- package/src/views/NewsletterEditor.tsx +42 -0
- package/src/views/NewsletterManager.tsx +483 -0
- package/src/views/SettingsView.tsx +216 -0
- package/src/views/SubscribersView.tsx +269 -0
- package/src/views/components/SendNewsletterModal.tsx +322 -0
- package/src/views/components/SmtpSettingsModal.tsx +433 -0
- package/src/views/components/TestEmailModal.tsx +268 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email Renderer
|
|
3
|
+
* Converts blocks to email-safe HTML
|
|
4
|
+
*
|
|
5
|
+
* Email constraints:
|
|
6
|
+
* - Inline styles only (no CSS classes)
|
|
7
|
+
* - Table-based layouts (no flexbox/grid)
|
|
8
|
+
* - Max width ~600px
|
|
9
|
+
* - Limited CSS support
|
|
10
|
+
* - Images need absolute URLs
|
|
11
|
+
* - No JavaScript
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Block } from '../../types/block';
|
|
15
|
+
import { blockRegistry } from '../../registry/BlockRegistry';
|
|
16
|
+
import { getChildBlocks } from '../utils/blockHelpers';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Email rendering context
|
|
20
|
+
*/
|
|
21
|
+
export interface EmailRenderContext {
|
|
22
|
+
siteId?: string;
|
|
23
|
+
locale?: string;
|
|
24
|
+
baseUrl?: string; // Base URL for images and links
|
|
25
|
+
logoUrl?: string; // Logo URL for email header
|
|
26
|
+
logoAlt?: string; // Logo alt text
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Convert a block to email-safe HTML
|
|
32
|
+
*/
|
|
33
|
+
export function renderBlockToEmail(block: Block, context: EmailRenderContext = {}): string {
|
|
34
|
+
const definition = blockRegistry.get(block.type);
|
|
35
|
+
|
|
36
|
+
// If block is registered and has Email component, use it
|
|
37
|
+
if (definition?.components?.Email) {
|
|
38
|
+
const childBlocks = block.children && Array.isArray(block.children) && block.children.length > 0
|
|
39
|
+
? (typeof block.children[0] === 'object' ? block.children as Block[] : [])
|
|
40
|
+
: [];
|
|
41
|
+
|
|
42
|
+
return definition.components.Email({
|
|
43
|
+
block,
|
|
44
|
+
context,
|
|
45
|
+
childBlocks,
|
|
46
|
+
renderChild: (childBlock: Block) => renderBlockToEmail(childBlock, context),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Fallback: render using built-in email-safe HTML (handles all standard block types)
|
|
51
|
+
return renderBlockByType(block, context);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Render block by type with email-safe HTML
|
|
56
|
+
*/
|
|
57
|
+
function renderBlockByType(block: Block, context: EmailRenderContext): string {
|
|
58
|
+
const { baseUrl = '' } = context;
|
|
59
|
+
|
|
60
|
+
switch (block.type) {
|
|
61
|
+
case 'heading': {
|
|
62
|
+
const level = (block.data.level as number) || 1;
|
|
63
|
+
const text = (block.data.text as string) || '';
|
|
64
|
+
const tag = `h${Math.min(Math.max(level, 1), 6)}`;
|
|
65
|
+
const fontSize = level === 1 ? '32px' : level === 2 ? '24px' : level === 3 ? '20px' : '18px';
|
|
66
|
+
return `<${tag} style="font-size: ${fontSize}; font-weight: bold; color: #1a2e26; margin: 20px 0 10px 0; line-height: 1.3;">${escapeHtml(text)}</${tag}>`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
case 'paragraph': {
|
|
70
|
+
const html = (block.data.html as string) || (block.data.text as string) || '';
|
|
71
|
+
// Convert basic HTML tags to email-safe inline styles
|
|
72
|
+
const emailHtml = convertHtmlToEmailSafe(html);
|
|
73
|
+
return `<p style="font-size: 15px; line-height: 1.8; color: #1a2e26; margin: 0 0 15px 0;">${emailHtml}</p>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case 'image': {
|
|
77
|
+
const imageId = block.data.imageId as string;
|
|
78
|
+
const alt = (block.data.alt as string) || '';
|
|
79
|
+
const caption = (block.data.caption as string) || '';
|
|
80
|
+
const height = (block.data.height as number) || undefined;
|
|
81
|
+
const widthPercent = (block.data.widthPercent as number) || 100;
|
|
82
|
+
const brightness = (block.data.brightness as number) ?? 100;
|
|
83
|
+
const blur = (block.data.blur as number) ?? 0;
|
|
84
|
+
const borderRadius = (block.data.borderRadius as string) || 'none';
|
|
85
|
+
|
|
86
|
+
if (!imageId) {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check if imageId is already a filename (has extension or starts with timestamp)
|
|
91
|
+
const hasFileExtension = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(imageId);
|
|
92
|
+
const looksLikeTimestamp = /^\d+-/.test(imageId);
|
|
93
|
+
const isFilename = hasFileExtension || looksLikeTimestamp;
|
|
94
|
+
|
|
95
|
+
// Generate image URL
|
|
96
|
+
const imageUrl = isFilename
|
|
97
|
+
? `${baseUrl}/api/uploads/${encodeURIComponent(imageId)}`
|
|
98
|
+
: `${baseUrl}/api/uploads/${encodeURIComponent(imageId)}`; // Will be resolved in preview component
|
|
99
|
+
|
|
100
|
+
// Build image styles
|
|
101
|
+
const imageStyles: string[] = [
|
|
102
|
+
'display: block',
|
|
103
|
+
'max-width: 100%',
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
// Apply width percentage
|
|
107
|
+
if (widthPercent !== 100) {
|
|
108
|
+
imageStyles.push(`width: ${widthPercent}%`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Apply height if specified
|
|
112
|
+
if (height) {
|
|
113
|
+
imageStyles.push(`height: ${height}px`);
|
|
114
|
+
imageStyles.push('object-fit: cover');
|
|
115
|
+
} else {
|
|
116
|
+
imageStyles.push('height: auto');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Apply filters (brightness and blur) - supported in most email clients
|
|
120
|
+
const filters: string[] = [];
|
|
121
|
+
if (brightness !== 100) {
|
|
122
|
+
filters.push(`brightness(${brightness}%)`);
|
|
123
|
+
}
|
|
124
|
+
if (blur > 0) {
|
|
125
|
+
filters.push(`blur(${blur}px)`);
|
|
126
|
+
}
|
|
127
|
+
if (filters.length > 0) {
|
|
128
|
+
imageStyles.push(`filter: ${filters.join(' ')}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Apply border radius
|
|
132
|
+
const borderRadiusMap: Record<string, string> = {
|
|
133
|
+
'none': '0',
|
|
134
|
+
'sm': '2px',
|
|
135
|
+
'md': '4px',
|
|
136
|
+
'lg': '8px',
|
|
137
|
+
'xl': '12px',
|
|
138
|
+
'2xl': '16px',
|
|
139
|
+
'3xl': '24px',
|
|
140
|
+
'full': '9999px',
|
|
141
|
+
};
|
|
142
|
+
const borderRadiusValue = borderRadiusMap[borderRadius] || '0';
|
|
143
|
+
if (borderRadiusValue !== '0') {
|
|
144
|
+
imageStyles.push(`border-radius: ${borderRadiusValue}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const imageStyleString = imageStyles.join('; ');
|
|
148
|
+
|
|
149
|
+
let html = `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin: 20px 0;">
|
|
150
|
+
<tr>
|
|
151
|
+
<td align="center">
|
|
152
|
+
<img src="${imageUrl}" alt="${escapeHtml(alt)}" style="${imageStyleString}" />
|
|
153
|
+
</td>
|
|
154
|
+
</tr>`;
|
|
155
|
+
|
|
156
|
+
if (caption) {
|
|
157
|
+
html += `<tr>
|
|
158
|
+
<td align="center" style="padding-top: 10px; font-size: 12px; color: #666; font-style: italic;">
|
|
159
|
+
${escapeHtml(caption)}
|
|
160
|
+
</td>
|
|
161
|
+
</tr>`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
html += `</table>`;
|
|
165
|
+
return html;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case 'list': {
|
|
169
|
+
const items = (block.data.items as Array<string | { text: string; html?: string }>) || [];
|
|
170
|
+
const type = (block.data.type as string) || 'ul';
|
|
171
|
+
const isOrdered = type === 'ol';
|
|
172
|
+
const tag = isOrdered ? 'ol' : 'ul';
|
|
173
|
+
|
|
174
|
+
const itemsHtml = items.map((item, idx) => {
|
|
175
|
+
const text = typeof item === 'string' ? item : (item.html || item.text || '');
|
|
176
|
+
const emailHtml = convertHtmlToEmailSafe(text);
|
|
177
|
+
return `<li style="margin: 8px 0; padding-left: 5px; line-height: 1.8; color: #1a2e26;">${emailHtml}</li>`;
|
|
178
|
+
}).join('');
|
|
179
|
+
|
|
180
|
+
return `<${tag} style="margin: 15px 0; padding-left: 25px; color: #1a2e26; font-size: 15px; line-height: 1.8;">${itemsHtml}</${tag}>`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
case 'table': {
|
|
184
|
+
const rows = (Array.isArray(block.data?.rows) ? block.data.rows : []) as Array<Array<{ text: string; html: string }>>;
|
|
185
|
+
const useHeader = block.data.useHeader ?? true;
|
|
186
|
+
|
|
187
|
+
if (!rows.length) {
|
|
188
|
+
return '';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Build table HTML using email-safe table structure
|
|
192
|
+
let tableHtml = `<table width="100%" cellpadding="12" cellspacing="0" border="0" style="margin: 20px 0; border-collapse: collapse; width: 100%;">`;
|
|
193
|
+
|
|
194
|
+
rows.forEach((row, rIdx) => {
|
|
195
|
+
const isHeaderRow = rIdx === 0 && useHeader;
|
|
196
|
+
const tag = isHeaderRow ? 'th' : 'td';
|
|
197
|
+
const bgColor = isHeaderRow ? '#1a2e26' : 'transparent';
|
|
198
|
+
const textColor = isHeaderRow ? '#ffffff' : '#1a2e26';
|
|
199
|
+
const fontWeight = isHeaderRow ? 'bold' : 'normal';
|
|
200
|
+
|
|
201
|
+
tableHtml += `<tr>`;
|
|
202
|
+
row.forEach((cell) => {
|
|
203
|
+
const cellHtml = convertHtmlToEmailSafe(cell.html || cell.text || '');
|
|
204
|
+
tableHtml += `<${tag} style="padding: 12px; border: 1px solid #e5e7eb; text-align: left; color: ${textColor}; font-size: 15px; line-height: 1.8; background-color: ${bgColor}; font-weight: ${fontWeight};">${cellHtml}</${tag}>`;
|
|
205
|
+
});
|
|
206
|
+
tableHtml += `</tr>`;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
tableHtml += `</table>`;
|
|
210
|
+
return tableHtml;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case 'divider': {
|
|
214
|
+
return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin: 30px 0;">
|
|
215
|
+
<tr>
|
|
216
|
+
<td align="center">
|
|
217
|
+
<table width="40" cellpadding="0" cellspacing="0" border="0">
|
|
218
|
+
<tr>
|
|
219
|
+
<td height="1" bgcolor="#1a2e26" style="background-color: #1a2e2620; height: 1px;"></td>
|
|
220
|
+
</tr>
|
|
221
|
+
</table>
|
|
222
|
+
</td>
|
|
223
|
+
</tr>
|
|
224
|
+
</table>`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
default:
|
|
228
|
+
// Generic fallback - try to extract text content
|
|
229
|
+
const text = extractTextFromBlock(block);
|
|
230
|
+
if (text) {
|
|
231
|
+
return `<p style="font-size: 15px; line-height: 1.8; color: #1a2e26; margin: 0 0 15px 0;">${escapeHtml(text)}</p>`;
|
|
232
|
+
}
|
|
233
|
+
return '';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Convert HTML to email-safe HTML (inline styles)
|
|
239
|
+
*/
|
|
240
|
+
function convertHtmlToEmailSafe(html: string): string {
|
|
241
|
+
if (!html) return '';
|
|
242
|
+
|
|
243
|
+
// Convert common HTML tags to email-safe versions with inline styles
|
|
244
|
+
let result = html
|
|
245
|
+
// Bold
|
|
246
|
+
.replace(/<strong\b[^>]*>(.*?)<\/strong>/gi, '<strong style="font-weight: bold;">$1</strong>')
|
|
247
|
+
.replace(/<b\b[^>]*>(.*?)<\/b>/gi, '<strong style="font-weight: bold;">$1</strong>')
|
|
248
|
+
// Italic
|
|
249
|
+
.replace(/<em\b[^>]*>(.*?)<\/em>/gi, '<em style="font-style: italic;">$1</em>')
|
|
250
|
+
.replace(/<i\b[^>]*>(.*?)<\/i>/gi, '<em style="font-style: italic;">$1</em>')
|
|
251
|
+
// Links
|
|
252
|
+
.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, '<a href="$1" style="color: #1a2e26; text-decoration: underline;">$2</a>')
|
|
253
|
+
// Line breaks
|
|
254
|
+
.replace(/<br\s*\/?>/gi, '<br />');
|
|
255
|
+
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Extract text content from block data
|
|
261
|
+
*/
|
|
262
|
+
function extractTextFromBlock(block: Block): string {
|
|
263
|
+
if (block.data.text) return String(block.data.text);
|
|
264
|
+
if (block.data.html) {
|
|
265
|
+
const html = String(block.data.html);
|
|
266
|
+
return html.replace(/<[^>]*>/g, '').trim();
|
|
267
|
+
}
|
|
268
|
+
if (block.data.title) return String(block.data.title);
|
|
269
|
+
return '';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Escape HTML entities
|
|
274
|
+
*/
|
|
275
|
+
function escapeHtml(text: string): string {
|
|
276
|
+
const map: Record<string, string> = {
|
|
277
|
+
'&': '&',
|
|
278
|
+
'<': '<',
|
|
279
|
+
'>': '>',
|
|
280
|
+
'"': '"',
|
|
281
|
+
"'": ''',
|
|
282
|
+
};
|
|
283
|
+
return text.replace(/[&<>"']/g, (m) => map[m]);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Render all blocks to email HTML
|
|
288
|
+
*/
|
|
289
|
+
export function renderBlocksToEmail(blocks: Block[], context: EmailRenderContext = {}): string {
|
|
290
|
+
return blocks.map(block => renderBlockToEmail(block, context)).join('\n');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Generate complete email HTML from newsletter
|
|
295
|
+
* Uses the same styling as the welcome email template
|
|
296
|
+
*/
|
|
297
|
+
export function generateNewsletterEmailHtml(
|
|
298
|
+
blocks: Block[],
|
|
299
|
+
metadata: {
|
|
300
|
+
subject: string;
|
|
301
|
+
previewText?: string;
|
|
302
|
+
},
|
|
303
|
+
context: EmailRenderContext & {
|
|
304
|
+
unsubscribeUrl?: string;
|
|
305
|
+
footerText?: string;
|
|
306
|
+
logoUrl?: string;
|
|
307
|
+
logoAlt?: string;
|
|
308
|
+
locale?: string;
|
|
309
|
+
}
|
|
310
|
+
): string {
|
|
311
|
+
const { baseUrl = '', unsubscribeUrl, footerText, logoUrl, logoAlt = 'Logo', locale = 'en' } = context;
|
|
312
|
+
const contentHtml = renderBlocksToEmail(blocks, context);
|
|
313
|
+
|
|
314
|
+
// Get unsubscribe text based on locale (matching welcome email)
|
|
315
|
+
const isDutch = locale === 'nl';
|
|
316
|
+
const unsubscribeText = isDutch ? 'Afmelden' : 'Unsubscribe';
|
|
317
|
+
|
|
318
|
+
// Build header HTML if logo is provided
|
|
319
|
+
const headerHtml = logoUrl ? `
|
|
320
|
+
<div class="header">
|
|
321
|
+
<img src="${logoUrl}" alt="${escapeHtml(logoAlt)}" class="logo">
|
|
322
|
+
</div>` : '';
|
|
323
|
+
|
|
324
|
+
return `<!DOCTYPE html>
|
|
325
|
+
<html>
|
|
326
|
+
<head>
|
|
327
|
+
<meta charset="utf-8">
|
|
328
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
329
|
+
<meta name="color-scheme" content="light">
|
|
330
|
+
<style>
|
|
331
|
+
body {
|
|
332
|
+
background-color: #faf9f6;
|
|
333
|
+
margin: 0;
|
|
334
|
+
padding: 0;
|
|
335
|
+
font-family: 'Georgia', serif;
|
|
336
|
+
-webkit-font-smoothing: antialiased;
|
|
337
|
+
-moz-osx-font-smoothing: grayscale;
|
|
338
|
+
}
|
|
339
|
+
.container {
|
|
340
|
+
max-width: 600px;
|
|
341
|
+
margin: 20px auto;
|
|
342
|
+
background-color: #ffffff;
|
|
343
|
+
border-radius: 40px;
|
|
344
|
+
border: 1px solid #1a2e260d;
|
|
345
|
+
overflow: hidden;
|
|
346
|
+
}
|
|
347
|
+
.header {
|
|
348
|
+
padding: 40px 0 20px 0;
|
|
349
|
+
text-align: center;
|
|
350
|
+
}
|
|
351
|
+
.logo {
|
|
352
|
+
width: 180px;
|
|
353
|
+
height: auto;
|
|
354
|
+
}
|
|
355
|
+
.content {
|
|
356
|
+
padding: 0 50px 40px 50px;
|
|
357
|
+
color: #1a2e26;
|
|
358
|
+
line-height: 1.8;
|
|
359
|
+
font-size: 15px;
|
|
360
|
+
}
|
|
361
|
+
.footer {
|
|
362
|
+
padding: 40px 50px;
|
|
363
|
+
text-align: center;
|
|
364
|
+
font-family: sans-serif;
|
|
365
|
+
font-size: 10px;
|
|
366
|
+
color: #a1a1aa;
|
|
367
|
+
letter-spacing: 1px;
|
|
368
|
+
border-top: 1px solid #faf9f6;
|
|
369
|
+
}
|
|
370
|
+
h1 {
|
|
371
|
+
font-weight: normal;
|
|
372
|
+
font-style: italic;
|
|
373
|
+
font-size: 30px;
|
|
374
|
+
margin-bottom: 30px;
|
|
375
|
+
color: #1a2e26;
|
|
376
|
+
text-align: center;
|
|
377
|
+
}
|
|
378
|
+
.divider {
|
|
379
|
+
height: 1px;
|
|
380
|
+
width: 40px;
|
|
381
|
+
background-color: #1a2e2620;
|
|
382
|
+
margin: 30px auto;
|
|
383
|
+
}
|
|
384
|
+
@media only screen and (max-width: 600px) {
|
|
385
|
+
.container {
|
|
386
|
+
width: 100% !important;
|
|
387
|
+
border-radius: 0 !important;
|
|
388
|
+
}
|
|
389
|
+
.content {
|
|
390
|
+
padding: 0 30px 30px 30px !important;
|
|
391
|
+
}
|
|
392
|
+
.footer {
|
|
393
|
+
padding: 30px !important;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
</style>
|
|
397
|
+
</head>
|
|
398
|
+
<body>
|
|
399
|
+
<div class="container">
|
|
400
|
+
${headerHtml}
|
|
401
|
+
<div class="content">
|
|
402
|
+
${contentHtml}
|
|
403
|
+
${unsubscribeUrl ? `
|
|
404
|
+
<div class="divider"></div>
|
|
405
|
+
<p style="text-align: center; font-size: 12px; color: #a1a1aa;">
|
|
406
|
+
<a href="${unsubscribeUrl}" style="color: #a1a1aa; text-decoration: none;">
|
|
407
|
+
${unsubscribeText}
|
|
408
|
+
</a>
|
|
409
|
+
</p>
|
|
410
|
+
` : ''}
|
|
411
|
+
</div>
|
|
412
|
+
${footerText ? `
|
|
413
|
+
<div class="footer">
|
|
414
|
+
${footerText}
|
|
415
|
+
</div>
|
|
416
|
+
` : ''}
|
|
417
|
+
</div>
|
|
418
|
+
</body>
|
|
419
|
+
</html>`;
|
|
420
|
+
}
|
package/src/lib/i18n.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n Utility
|
|
3
|
+
* Simple translation loader for the newsletter plugin
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
|
|
9
|
+
function getLocalesDir(): string {
|
|
10
|
+
const possiblePaths = [
|
|
11
|
+
path.join(process.cwd(), 'node_modules', '@jhits', 'plugin-newsletter', 'data', 'locales'),
|
|
12
|
+
path.join(__dirname, '..', 'data', 'locales'),
|
|
13
|
+
path.join(__dirname, '..', '..', 'data', 'locales'),
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
for (const localesPath of possiblePaths) {
|
|
17
|
+
if (fs.existsSync(localesPath)) {
|
|
18
|
+
return localesPath;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return possiblePaths[0];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const localesDir = getLocalesDir();
|
|
26
|
+
const cache: Record<string, any> = {};
|
|
27
|
+
|
|
28
|
+
function loadLocale(locale: string): any {
|
|
29
|
+
if (cache[locale]) {
|
|
30
|
+
return cache[locale];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const localePath = path.join(localesDir, locale, 'common.json');
|
|
35
|
+
|
|
36
|
+
if (fs.existsSync(localePath)) {
|
|
37
|
+
const content = fs.readFileSync(localePath, 'utf-8');
|
|
38
|
+
cache[locale] = JSON.parse(content);
|
|
39
|
+
return cache[locale];
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error(`[i18n] Failed to load locale ${locale}:`, error);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getTranslations(locale: string): any {
|
|
49
|
+
const translations = loadLocale(locale);
|
|
50
|
+
|
|
51
|
+
if (translations) {
|
|
52
|
+
return translations;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const fallback = loadLocale('en');
|
|
56
|
+
return fallback || {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getTestEmailTranslations(language: string): {
|
|
60
|
+
title: string;
|
|
61
|
+
greeting: string;
|
|
62
|
+
description: string;
|
|
63
|
+
successTitle: string;
|
|
64
|
+
successMessage: string;
|
|
65
|
+
recipientLabel: string;
|
|
66
|
+
footerText: string;
|
|
67
|
+
ignoreText: string;
|
|
68
|
+
} {
|
|
69
|
+
const translations = getTranslations(language);
|
|
70
|
+
const enTranslations = getTranslations('en');
|
|
71
|
+
|
|
72
|
+
return translations.testEmail || enTranslations.testEmail || {
|
|
73
|
+
title: 'SMTP Configuration Verified',
|
|
74
|
+
greeting: 'Hello,',
|
|
75
|
+
description: 'This is a test email to verify that your SMTP configuration is working correctly.',
|
|
76
|
+
successTitle: 'Success!',
|
|
77
|
+
successMessage: 'Your SMTP settings are configured correctly.',
|
|
78
|
+
recipientLabel: 'Email sent to:',
|
|
79
|
+
footerText: 'This is an automated test email from',
|
|
80
|
+
ignoreText: 'If you did not expect to receive this email, you can safely ignore it.',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Mapper
|
|
3
|
+
* Converts editor state to API format and vice versa
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EditorState } from '../../state/types';
|
|
7
|
+
import { Newsletter, NewsletterPublicationData } from '../../types/newsletter';
|
|
8
|
+
import { Block } from '../../types/block';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convert editor state to API format
|
|
12
|
+
*/
|
|
13
|
+
export function editorStateToAPI(state: EditorState): {
|
|
14
|
+
title: string;
|
|
15
|
+
slug: string;
|
|
16
|
+
blocks: Block[];
|
|
17
|
+
metadata: {
|
|
18
|
+
subject: string;
|
|
19
|
+
previewText?: string;
|
|
20
|
+
lang?: string;
|
|
21
|
+
recipientFilter?: {
|
|
22
|
+
type: 'all' | 'language' | 'custom';
|
|
23
|
+
value?: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
publication: NewsletterPublicationData;
|
|
27
|
+
} {
|
|
28
|
+
// For newsletters, use subject as title if title is empty
|
|
29
|
+
// This makes sense since email subject is the primary identifier
|
|
30
|
+
const newsletterTitle = state.title?.trim() || state.metadata?.subject?.trim() || '';
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
title: newsletterTitle,
|
|
34
|
+
slug: state.slug,
|
|
35
|
+
blocks: state.blocks,
|
|
36
|
+
metadata: state.metadata,
|
|
37
|
+
publication: {
|
|
38
|
+
status: state.status,
|
|
39
|
+
authorId: undefined, // Will be set by API handler
|
|
40
|
+
updatedAt: new Date().toISOString(),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Convert API format to editor state
|
|
47
|
+
*/
|
|
48
|
+
export function apiToEditorState(newsletter: Newsletter): Partial<EditorState> {
|
|
49
|
+
return {
|
|
50
|
+
blocks: newsletter.blocks,
|
|
51
|
+
title: newsletter.title,
|
|
52
|
+
slug: newsletter.slug,
|
|
53
|
+
metadata: newsletter.metadata,
|
|
54
|
+
status: newsletter.publication.status,
|
|
55
|
+
newsletterId: newsletter.id,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block Helper Utilities
|
|
3
|
+
* Functions for working with blocks, including nested structures
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Block } from '../../types/block';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get child blocks from a container block
|
|
10
|
+
* Handles both Block[] and string[] formats
|
|
11
|
+
*/
|
|
12
|
+
export function getChildBlocks(block: Block, allBlocks: Block[] = []): Block[] {
|
|
13
|
+
if (!block.children || block.children.length === 0) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// If children are Block objects, return them directly
|
|
18
|
+
if (block.children.length > 0 && typeof block.children[0] === 'object' && 'id' in block.children[0]) {
|
|
19
|
+
return block.children as Block[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// If children are IDs, resolve them from allBlocks (recursively search nested blocks)
|
|
23
|
+
const childIds = block.children as string[];
|
|
24
|
+
const resolvedBlocks: Block[] = [];
|
|
25
|
+
|
|
26
|
+
function findBlockRecursive(blocks: Block[], id: string): Block | null {
|
|
27
|
+
for (const b of blocks) {
|
|
28
|
+
if (b.id === id) return b;
|
|
29
|
+
if (b.children && Array.isArray(b.children) && b.children.length > 0) {
|
|
30
|
+
if (typeof b.children[0] === 'object') {
|
|
31
|
+
const found = findBlockRecursive(b.children as Block[], id);
|
|
32
|
+
if (found) return found;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
childIds.forEach(id => {
|
|
40
|
+
const found = findBlockRecursive(allBlocks, id);
|
|
41
|
+
if (found) resolvedBlocks.push(found);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return resolvedBlocks;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if a block is a container (has children or is marked as container)
|
|
49
|
+
*/
|
|
50
|
+
export function isContainerBlock(block: Block, blockRegistry: { get: (type: string) => { isContainer?: boolean } | undefined }): boolean {
|
|
51
|
+
const definition = blockRegistry.get(block.type);
|
|
52
|
+
return definition?.isContainer === true || (block.children !== undefined && block.children.length > 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Find a block by ID recursively (including nested blocks)
|
|
57
|
+
*/
|
|
58
|
+
export function findBlockById(blocks: Block[], id: string): Block | null {
|
|
59
|
+
for (const block of blocks) {
|
|
60
|
+
if (block.id === id) {
|
|
61
|
+
return block;
|
|
62
|
+
}
|
|
63
|
+
if (block.children && Array.isArray(block.children) && block.children.length > 0) {
|
|
64
|
+
if (typeof block.children[0] === 'object') {
|
|
65
|
+
const found = findBlockById(block.children as Block[], id);
|
|
66
|
+
if (found) return found;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slug Utilities
|
|
3
|
+
* Functions for generating and validating URL slugs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Convert a string to a URL-friendly slug
|
|
8
|
+
*/
|
|
9
|
+
export function slugify(text: string): string {
|
|
10
|
+
return text
|
|
11
|
+
.toString()
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.trim()
|
|
14
|
+
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
15
|
+
.replace(/[^\w\-]+/g, '') // Remove all non-word chars
|
|
16
|
+
.replace(/\-\-+/g, '-') // Replace multiple hyphens with single hyphen
|
|
17
|
+
.replace(/^-+/, '') // Trim hyphens from start
|
|
18
|
+
.replace(/-+$/, ''); // Trim hyphens from end
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate a slug from a title
|
|
23
|
+
* Automatically handles edge cases and ensures uniqueness
|
|
24
|
+
*/
|
|
25
|
+
export function generateSlugFromTitle(title: string, existingSlugs: string[] = []): string {
|
|
26
|
+
let baseSlug = slugify(title);
|
|
27
|
+
|
|
28
|
+
// If slug is empty after processing, use a fallback
|
|
29
|
+
if (!baseSlug) {
|
|
30
|
+
baseSlug = 'untitled-newsletter';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Check for collisions and append number if needed
|
|
34
|
+
let finalSlug = baseSlug;
|
|
35
|
+
let counter = 1;
|
|
36
|
+
|
|
37
|
+
while (existingSlugs.includes(finalSlug)) {
|
|
38
|
+
finalSlug = `${baseSlug}-${counter}`;
|
|
39
|
+
counter++;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return finalSlug;
|
|
43
|
+
}
|