@jhits/plugin-newsletter 0.0.16 → 0.0.17
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/api/email-utils.d.ts.map +1 -1
- package/dist/api/email-utils.js +45 -4
- package/dist/api/handlers/send-newsletter.d.ts.map +1 -1
- package/dist/api/handlers/send-newsletter.js +54 -6
- package/dist/api/handlers/settings.d.ts.map +1 -1
- package/dist/api/handlers/settings.js +51 -1
- package/dist/index.d.ts +27 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -122
- package/dist/lib/blocks/BlockRenderer.d.ts.map +1 -1
- package/dist/lib/blocks/BlockRenderer.js +14 -2
- package/dist/lib/email/EmailRenderer.d.ts +1 -0
- package/dist/lib/email/EmailRenderer.d.ts.map +1 -1
- package/dist/lib/email/EmailRenderer.js +31 -19
- package/dist/lib/utils/config-resolver.d.ts +33 -0
- package/dist/lib/utils/config-resolver.d.ts.map +1 -0
- package/dist/lib/utils/config-resolver.js +47 -0
- package/dist/registry/BlockRegistry.d.ts +9 -1
- package/dist/registry/BlockRegistry.d.ts.map +1 -1
- package/dist/registry/BlockRegistry.js +126 -8
- package/dist/state/EditorContext.d.ts +11 -1
- package/dist/state/EditorContext.d.ts.map +1 -1
- package/dist/state/EditorContext.js +23 -5
- package/dist/state/types.d.ts +12 -0
- package/dist/state/types.d.ts.map +1 -1
- package/dist/types/block.d.ts +9 -0
- package/dist/types/block.d.ts.map +1 -1
- package/dist/types/newsletter.d.ts +2 -0
- package/dist/types/newsletter.d.ts.map +1 -1
- package/dist/views/CanvasEditor/BlockWrapper.d.ts.map +1 -1
- package/dist/views/CanvasEditor/BlockWrapper.js +24 -3
- package/dist/views/CanvasEditor/CanvasEditorView.d.ts.map +1 -1
- package/dist/views/CanvasEditor/CanvasEditorView.js +77 -17
- package/dist/views/CanvasEditor/EditorBody.d.ts.map +1 -1
- package/dist/views/CanvasEditor/EditorBody.js +1 -1
- package/dist/views/CanvasEditor/components/EditorCanvas.d.ts.map +1 -1
- package/dist/views/CanvasEditor/components/EditorCanvas.js +158 -100
- package/dist/views/CanvasEditor/components/EditorSidebar.d.ts +3 -1
- package/dist/views/CanvasEditor/components/EditorSidebar.d.ts.map +1 -1
- package/dist/views/CanvasEditor/components/EditorSidebar.js +3 -3
- package/dist/views/CanvasEditor/hooks/useRegisteredBlocks.d.ts +1 -1
- package/dist/views/CanvasEditor/hooks/useRegisteredBlocks.d.ts.map +1 -1
- package/dist/views/CanvasEditor/hooks/useRegisteredBlocks.js +6 -40
- package/dist/views/components/DomainPromptModal.d.ts +13 -0
- package/dist/views/components/DomainPromptModal.d.ts.map +1 -0
- package/dist/views/components/DomainPromptModal.js +58 -0
- package/dist/views/components/SendNewsletterModal.d.ts.map +1 -1
- package/dist/views/components/SendNewsletterModal.js +91 -22
- package/dist/views/components/SmtpSettingsModal.d.ts.map +1 -1
- package/dist/views/components/SmtpSettingsModal.js +10 -0
- package/dist/views/components/TestEmailModal.d.ts.map +1 -1
- package/dist/views/components/TestEmailModal.js +86 -17
- package/package.json +53 -9
- package/src/api/email-utils.ts +53 -4
- package/src/api/handlers/send-newsletter.ts +65 -6
- package/src/api/handlers/settings.ts +60 -2
- package/src/index.tsx +49 -155
- package/src/lib/blocks/BlockRenderer.tsx +16 -2
- package/src/lib/email/EmailRenderer.tsx +31 -20
- package/src/lib/utils/config-resolver.ts +71 -0
- package/src/registry/BlockRegistry.tsx +255 -0
- package/src/state/EditorContext.tsx +43 -8
- package/src/state/types.ts +16 -0
- package/src/types/block.ts +10 -0
- package/src/types/newsletter.ts +3 -0
- package/src/views/CanvasEditor/BlockWrapper.tsx +27 -2
- package/src/views/CanvasEditor/CanvasEditorView.tsx +142 -61
- package/src/views/CanvasEditor/EditorBody.tsx +17 -13
- package/src/views/CanvasEditor/components/EditorCanvas.tsx +178 -115
- package/src/views/CanvasEditor/components/EditorSidebar.tsx +57 -2
- package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +6 -45
- package/src/views/components/DomainPromptModal.tsx +160 -0
- package/src/views/components/SendNewsletterModal.tsx +270 -184
- package/src/views/components/SmtpSettingsModal.tsx +11 -0
- package/src/views/components/TestEmailModal.tsx +235 -149
- package/src/registry/BlockRegistry.ts +0 -53
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
|
|
14
14
|
import { Block } from '../../types/block';
|
|
15
15
|
import { blockRegistry } from '../../registry/BlockRegistry';
|
|
16
|
-
import { getChildBlocks } from '../utils/blockHelpers';
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* Email rendering context
|
|
@@ -62,15 +61,25 @@ function renderBlockByType(block: Block, context: EmailRenderContext): string {
|
|
|
62
61
|
const level = (block.data.level as number) || 1;
|
|
63
62
|
const text = (block.data.text as string) || '';
|
|
64
63
|
const tag = `h${Math.min(Math.max(level, 1), 6)}`;
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
|
|
65
|
+
// Aligned with BotanicsAndYou heading sizes
|
|
66
|
+
let fontSize = '30px';
|
|
67
|
+
if (level === 1) fontSize = '48px';
|
|
68
|
+
if (level === 2) fontSize = '36px';
|
|
69
|
+
if (level === 3) fontSize = '30px';
|
|
70
|
+
if (level >= 4) fontSize = '20px';
|
|
71
|
+
|
|
72
|
+
const fontWeight = level >= 4 ? '500' : 'bold';
|
|
73
|
+
|
|
74
|
+
return `<${tag} style="font-family: 'Georgia', serif; font-size: ${fontSize}; font-weight: ${fontWeight}; color: #1a2e26; margin: 40px 0 15px 0; line-height: 1.2;">${escapeHtml(text)}</${tag}>`;
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
case 'paragraph': {
|
|
70
78
|
const html = (block.data.html as string) || (block.data.text as string) || '';
|
|
71
79
|
// Convert basic HTML tags to email-safe inline styles
|
|
72
80
|
const emailHtml = convertHtmlToEmailSafe(html);
|
|
73
|
-
|
|
81
|
+
// Aligned with BotanicsAndYou paragraph style (text-lg = 18px)
|
|
82
|
+
return `<p style="font-family: 'Georgia', serif; font-size: 18px; line-height: 1.625; color: #1a2e26; margin: 0 0 24px 0;">${emailHtml}</p>`;
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
case 'image': {
|
|
@@ -146,7 +155,7 @@ function renderBlockByType(block: Block, context: EmailRenderContext): string {
|
|
|
146
155
|
|
|
147
156
|
const imageStyleString = imageStyles.join('; ');
|
|
148
157
|
|
|
149
|
-
let html = `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:
|
|
158
|
+
let html = `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin: 30px 0;">
|
|
150
159
|
<tr>
|
|
151
160
|
<td align="center">
|
|
152
161
|
<img src="${imageUrl}" alt="${escapeHtml(alt)}" style="${imageStyleString}" />
|
|
@@ -155,7 +164,7 @@ function renderBlockByType(block: Block, context: EmailRenderContext): string {
|
|
|
155
164
|
|
|
156
165
|
if (caption) {
|
|
157
166
|
html += `<tr>
|
|
158
|
-
<td align="center" style="padding-top: 10px; font-size:
|
|
167
|
+
<td align="center" style="padding-top: 10px; font-size: 14px; color: #666; font-style: italic; font-family: 'Georgia', serif;">
|
|
159
168
|
${escapeHtml(caption)}
|
|
160
169
|
</td>
|
|
161
170
|
</tr>`;
|
|
@@ -174,10 +183,10 @@ function renderBlockByType(block: Block, context: EmailRenderContext): string {
|
|
|
174
183
|
const itemsHtml = items.map((item, idx) => {
|
|
175
184
|
const text = typeof item === 'string' ? item : (item.html || item.text || '');
|
|
176
185
|
const emailHtml = convertHtmlToEmailSafe(text);
|
|
177
|
-
return `<li style="margin:
|
|
186
|
+
return `<li style="margin: 12px 0; padding-left: 5px; line-height: 1.625; color: #1a2e26;">${emailHtml}</li>`;
|
|
178
187
|
}).join('');
|
|
179
188
|
|
|
180
|
-
return `<${tag} style="margin:
|
|
189
|
+
return `<${tag} style="font-family: 'Georgia', serif; margin: 24px 0; padding-left: 24px; color: #1a2e26; font-size: 18px; line-height: 1.625;">${itemsHtml}</${tag}>`;
|
|
181
190
|
}
|
|
182
191
|
|
|
183
192
|
case 'table': {
|
|
@@ -189,7 +198,7 @@ function renderBlockByType(block: Block, context: EmailRenderContext): string {
|
|
|
189
198
|
}
|
|
190
199
|
|
|
191
200
|
// Build table HTML using email-safe table structure
|
|
192
|
-
let tableHtml = `<table width="100%" cellpadding="12" cellspacing="0" border="0" style="margin:
|
|
201
|
+
let tableHtml = `<table width="100%" cellpadding="12" cellspacing="0" border="0" style="margin: 30px 0; border-collapse: collapse; width: 100%; font-family: 'Georgia', serif;">`;
|
|
193
202
|
|
|
194
203
|
rows.forEach((row, rIdx) => {
|
|
195
204
|
const isHeaderRow = rIdx === 0 && useHeader;
|
|
@@ -201,7 +210,7 @@ function renderBlockByType(block: Block, context: EmailRenderContext): string {
|
|
|
201
210
|
tableHtml += `<tr>`;
|
|
202
211
|
row.forEach((cell) => {
|
|
203
212
|
const cellHtml = convertHtmlToEmailSafe(cell.html || cell.text || '');
|
|
204
|
-
tableHtml += `<${tag} style="padding:
|
|
213
|
+
tableHtml += `<${tag} style="padding: 15px; border: 1px solid #e5e7eb; text-align: left; color: ${textColor}; font-size: 16px; line-height: 1.6; background-color: ${bgColor}; font-weight: ${fontWeight};">${cellHtml}</${tag}>`;
|
|
205
214
|
});
|
|
206
215
|
tableHtml += `</tr>`;
|
|
207
216
|
});
|
|
@@ -211,7 +220,7 @@ function renderBlockByType(block: Block, context: EmailRenderContext): string {
|
|
|
211
220
|
}
|
|
212
221
|
|
|
213
222
|
case 'divider': {
|
|
214
|
-
return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:
|
|
223
|
+
return `<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin: 40px 0;">
|
|
215
224
|
<tr>
|
|
216
225
|
<td align="center">
|
|
217
226
|
<table width="40" cellpadding="0" cellspacing="0" border="0">
|
|
@@ -228,7 +237,7 @@ function renderBlockByType(block: Block, context: EmailRenderContext): string {
|
|
|
228
237
|
// Generic fallback - try to extract text content
|
|
229
238
|
const text = extractTextFromBlock(block);
|
|
230
239
|
if (text) {
|
|
231
|
-
return `<p style="font-size:
|
|
240
|
+
return `<p style="font-family: 'Georgia', serif; font-size: 18px; line-height: 1.625; color: #1a2e26; margin: 0 0 24px 0;">${escapeHtml(text)}</p>`;
|
|
232
241
|
}
|
|
233
242
|
return '';
|
|
234
243
|
}
|
|
@@ -302,18 +311,20 @@ export function generateNewsletterEmailHtml(
|
|
|
302
311
|
},
|
|
303
312
|
context: EmailRenderContext & {
|
|
304
313
|
unsubscribeUrl?: string;
|
|
314
|
+
unsubscribeText?: string;
|
|
305
315
|
footerText?: string;
|
|
306
316
|
logoUrl?: string;
|
|
307
317
|
logoAlt?: string;
|
|
308
318
|
locale?: string;
|
|
309
319
|
}
|
|
310
320
|
): string {
|
|
311
|
-
const { baseUrl = '', unsubscribeUrl, footerText, logoUrl, logoAlt = 'Logo', locale = 'en' } = context;
|
|
321
|
+
const { baseUrl = '', unsubscribeUrl, unsubscribeText: propsUnsubscribeText, footerText, logoUrl, logoAlt = 'Logo', locale = 'en' } = context;
|
|
312
322
|
const contentHtml = renderBlocksToEmail(blocks, context);
|
|
313
323
|
|
|
314
324
|
// Get unsubscribe text based on locale (matching welcome email)
|
|
315
325
|
const isDutch = locale === 'nl';
|
|
316
|
-
const
|
|
326
|
+
const defaultText = isDutch ? 'Afmelden' : 'Unsubscribe';
|
|
327
|
+
const unsubscribeText = propsUnsubscribeText || defaultText;
|
|
317
328
|
|
|
318
329
|
// Build header HTML if logo is provided
|
|
319
330
|
const headerHtml = logoUrl ? `
|
|
@@ -355,8 +366,8 @@ export function generateNewsletterEmailHtml(
|
|
|
355
366
|
.content {
|
|
356
367
|
padding: 0 50px 40px 50px;
|
|
357
368
|
color: #1a2e26;
|
|
358
|
-
line-height: 1.
|
|
359
|
-
font-size:
|
|
369
|
+
line-height: 1.625;
|
|
370
|
+
font-size: 18px;
|
|
360
371
|
}
|
|
361
372
|
.footer {
|
|
362
373
|
padding: 40px 50px;
|
|
@@ -368,12 +379,12 @@ export function generateNewsletterEmailHtml(
|
|
|
368
379
|
border-top: 1px solid #faf9f6;
|
|
369
380
|
}
|
|
370
381
|
h1 {
|
|
371
|
-
font-weight:
|
|
372
|
-
font-
|
|
373
|
-
font-size: 30px;
|
|
382
|
+
font-weight: bold;
|
|
383
|
+
font-size: 48px;
|
|
374
384
|
margin-bottom: 30px;
|
|
375
385
|
color: #1a2e26;
|
|
376
|
-
text-align:
|
|
386
|
+
text-align: left;
|
|
387
|
+
line-height: 1.2;
|
|
377
388
|
}
|
|
378
389
|
.divider {
|
|
379
390
|
height: 1px;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Newsletter Plugin Configuration Resolver
|
|
3
|
+
* Centralizes the logic for resolving plugin settings from multiple sources
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ClientBlockDefinition } from '../../types/block';
|
|
7
|
+
|
|
8
|
+
export interface ResolvedConfig {
|
|
9
|
+
customBlocks: ClientBlockDefinition[];
|
|
10
|
+
darkMode: boolean;
|
|
11
|
+
backgroundColors?: {
|
|
12
|
+
light: string;
|
|
13
|
+
dark?: string;
|
|
14
|
+
};
|
|
15
|
+
translations?: Record<string, any>;
|
|
16
|
+
unsubscribeTranslations?: Record<string, string>;
|
|
17
|
+
emailConfig?: {
|
|
18
|
+
logoUrl?: string;
|
|
19
|
+
logoAlt?: string;
|
|
20
|
+
footerText?: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolves a specific configuration value from priority sources:
|
|
26
|
+
* 1. LocalStorage (Dev overrides)
|
|
27
|
+
* 2. Component Props
|
|
28
|
+
* 3. Window Global (__JHITS_PLUGIN_PROPS__)
|
|
29
|
+
* 4. Default Value
|
|
30
|
+
*/
|
|
31
|
+
export function resolveConfigValue<T>(
|
|
32
|
+
key: string,
|
|
33
|
+
propValue: T | undefined,
|
|
34
|
+
defaultValue: T,
|
|
35
|
+
pluginKey: string = 'plugin-newsletter'
|
|
36
|
+
): T {
|
|
37
|
+
if (typeof window === 'undefined') return propValue ?? defaultValue;
|
|
38
|
+
|
|
39
|
+
// 1. Check LocalStorage (Dev priority)
|
|
40
|
+
try {
|
|
41
|
+
const saved = localStorage.getItem(`__JHITS_PLUGIN_NEWSLETTER_CONFIG__`);
|
|
42
|
+
if (saved) {
|
|
43
|
+
const config = JSON.parse(saved);
|
|
44
|
+
if (config[key] !== undefined) return config[key];
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {}
|
|
47
|
+
|
|
48
|
+
// 2. Check Props
|
|
49
|
+
if (propValue !== undefined) return propValue;
|
|
50
|
+
|
|
51
|
+
// 3. Check Window Global
|
|
52
|
+
const globalProps = (window as any).__JHITS_PLUGIN_PROPS__?.[pluginKey] || (window as any).__JHITS_PLUGIN_PROPS__?.['newsletter'];
|
|
53
|
+
if (globalProps && globalProps[key] !== undefined) return globalProps[key];
|
|
54
|
+
|
|
55
|
+
// 4. Default
|
|
56
|
+
return defaultValue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolves all plugin configuration
|
|
61
|
+
*/
|
|
62
|
+
export function resolvePluginConfig(props: any): ResolvedConfig {
|
|
63
|
+
return {
|
|
64
|
+
customBlocks: resolveConfigValue('customBlocks', props.customBlocks, []),
|
|
65
|
+
darkMode: resolveConfigValue('darkMode', props.darkMode, true),
|
|
66
|
+
backgroundColors: resolveConfigValue('backgroundColors', props.backgroundColors, undefined),
|
|
67
|
+
translations: resolveConfigValue('translations', props.translations, undefined),
|
|
68
|
+
unsubscribeTranslations: resolveConfigValue('unsubscribeTranslations', props.unsubscribeTranslations, undefined),
|
|
69
|
+
emailConfig: resolveConfigValue('emailConfig', props.emailConfig, undefined),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block Registry for Newsletter Plugin
|
|
3
|
+
* Dynamic registry for all block types in system
|
|
4
|
+
* Multi-Tenant Architecture: Blocks are provided by client applications
|
|
5
|
+
*
|
|
6
|
+
* The registry is a singleton that starts empty and is populated by client apps
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React from 'react';
|
|
10
|
+
import {
|
|
11
|
+
BlockTypeDefinition,
|
|
12
|
+
ClientBlockDefinition,
|
|
13
|
+
BlockEditProps,
|
|
14
|
+
BlockPreviewProps
|
|
15
|
+
} from '../types/block';
|
|
16
|
+
import {
|
|
17
|
+
Type,
|
|
18
|
+
Heading as HeadingIcon,
|
|
19
|
+
List,
|
|
20
|
+
Image as ImageIcon,
|
|
21
|
+
Table as TableIcon,
|
|
22
|
+
Minus
|
|
23
|
+
} from 'lucide-react';
|
|
24
|
+
|
|
25
|
+
// --- Default Block Components ---
|
|
26
|
+
|
|
27
|
+
// Paragraph
|
|
28
|
+
const DefaultParagraphEdit: React.FC<BlockEditProps> = ({ block, onUpdate }) => (
|
|
29
|
+
<textarea
|
|
30
|
+
value={(block.data.text as string) || ''}
|
|
31
|
+
onChange={(e) => onUpdate({ text: e.target.value })}
|
|
32
|
+
onInput={(e) => {
|
|
33
|
+
const target = e.target as HTMLTextAreaElement;
|
|
34
|
+
target.style.height = 'auto';
|
|
35
|
+
target.style.height = `${target.scrollHeight}px`;
|
|
36
|
+
}}
|
|
37
|
+
placeholder="Type your text..."
|
|
38
|
+
className="w-full bg-transparent border-none outline-none focus:ring-0 p-0 resize-none min-h-[1.5em] font-serif text-[18px] leading-[1.625] text-[#1a2e26] overflow-hidden"
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
const DefaultParagraphPreview: React.FC<BlockPreviewProps> = ({ block }) => (
|
|
42
|
+
<p
|
|
43
|
+
className="font-serif text-[18px] leading-[1.625] text-[#1a2e26]"
|
|
44
|
+
dangerouslySetInnerHTML={{ __html: (block.data.html as string) || (block.data.text as string) || '' }}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Heading
|
|
49
|
+
const DefaultHeadingEdit: React.FC<BlockEditProps> = ({ block, onUpdate }) => {
|
|
50
|
+
const level = (block.data.level as number) || 2;
|
|
51
|
+
const fontSize = level === 1 ? '48px' : level === 2 ? '36px' : level === 3 ? '30px' : '20px';
|
|
52
|
+
return (
|
|
53
|
+
<input
|
|
54
|
+
type="text"
|
|
55
|
+
value={(block.data.text as string) || ''}
|
|
56
|
+
onChange={(e) => onUpdate({ text: e.target.value })}
|
|
57
|
+
placeholder="Heading..."
|
|
58
|
+
style={{ fontSize, lineHeight: '1.2' }}
|
|
59
|
+
className="w-full bg-transparent border-none outline-none focus:ring-0 p-0 font-serif font-bold text-[#1a2e26]"
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
const DefaultHeadingPreview: React.FC<BlockPreviewProps> = ({ block }) => {
|
|
64
|
+
const level = (block.data.level as number) || 2;
|
|
65
|
+
const fontSize = level === 1 ? '48px' : level === 2 ? '36px' : level === 3 ? '30px' : '20px';
|
|
66
|
+
const Tag = `h${level}` as keyof React.JSX.IntrinsicElements;
|
|
67
|
+
return (
|
|
68
|
+
<Tag
|
|
69
|
+
style={{ fontSize, lineHeight: '1.2' }}
|
|
70
|
+
className="font-serif font-bold text-[#1a2e26]"
|
|
71
|
+
dangerouslySetInnerHTML={{ __html: (block.data.text as string) || '' }}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// List
|
|
77
|
+
const DefaultListEdit: React.FC<BlockEditProps> = ({ block, onUpdate }) => {
|
|
78
|
+
const items = Array.isArray(block.data.items) ? block.data.items : [''];
|
|
79
|
+
return (
|
|
80
|
+
<div className="space-y-2">
|
|
81
|
+
{items.map((item, i) => (
|
|
82
|
+
<div key={i} className="flex gap-2">
|
|
83
|
+
<span className="text-neutral-400">•</span>
|
|
84
|
+
<input
|
|
85
|
+
type="text"
|
|
86
|
+
value={typeof item === 'string' ? item : item.text || ''}
|
|
87
|
+
onChange={(e) => {
|
|
88
|
+
const newItems = [...items];
|
|
89
|
+
newItems[i] = e.target.value;
|
|
90
|
+
onUpdate({ items: newItems });
|
|
91
|
+
}}
|
|
92
|
+
className="flex-1 bg-transparent border-none outline-none focus:ring-0 p-0 text-[18px] leading-[1.625] font-serif"
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
))}
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
onClick={() => onUpdate({ items: [...items, ''] })}
|
|
99
|
+
className="text-xs text-primary font-bold hover:underline"
|
|
100
|
+
>
|
|
101
|
+
+ Add Item
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
const DefaultListPreview: React.FC<BlockPreviewProps> = ({ block }) => {
|
|
107
|
+
const items = Array.isArray(block.data.items) ? block.data.items : [];
|
|
108
|
+
const Tag = block.data.type === 'ol' ? 'ol' : 'ul';
|
|
109
|
+
return (
|
|
110
|
+
<Tag className="list-disc pl-5 space-y-1 font-serif text-[18px] leading-[1.625] text-[#1a2e26]">
|
|
111
|
+
{items.map((item, i) => (
|
|
112
|
+
<li key={i}>{typeof item === 'string' ? item : item.text || ''}</li>
|
|
113
|
+
))}
|
|
114
|
+
</Tag>
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Local interface to avoid import issues
|
|
119
|
+
interface IBlockRegistry {
|
|
120
|
+
register(definition: BlockTypeDefinition): void;
|
|
121
|
+
registerClientBlocks(definitions: ClientBlockDefinition[]): void;
|
|
122
|
+
get(type: string): BlockTypeDefinition | undefined;
|
|
123
|
+
getAll(): BlockTypeDefinition[];
|
|
124
|
+
has(type: string): boolean;
|
|
125
|
+
clear(): void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Block Registry Implementation
|
|
130
|
+
* Singleton that manages all block types in the system
|
|
131
|
+
*/
|
|
132
|
+
class BlockRegistryImpl implements IBlockRegistry {
|
|
133
|
+
private blocks: Map<string, BlockTypeDefinition> = new Map();
|
|
134
|
+
|
|
135
|
+
constructor() {
|
|
136
|
+
// Register default blocks on instantiation (server & client)
|
|
137
|
+
this.registerDefaultBlocks();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private registerDefaultBlocks(): void {
|
|
141
|
+
const defaults: BlockTypeDefinition[] = [
|
|
142
|
+
{
|
|
143
|
+
type: 'paragraph',
|
|
144
|
+
name: 'Paragraph',
|
|
145
|
+
description: 'Simple text paragraph',
|
|
146
|
+
icon: Type,
|
|
147
|
+
category: 'text',
|
|
148
|
+
defaultData: { text: '' },
|
|
149
|
+
components: { Edit: DefaultParagraphEdit, Preview: DefaultParagraphPreview, Icon: Type }
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
type: 'heading',
|
|
153
|
+
name: 'Heading',
|
|
154
|
+
description: 'Section heading',
|
|
155
|
+
icon: HeadingIcon,
|
|
156
|
+
category: 'text',
|
|
157
|
+
defaultData: { text: '', level: 2 },
|
|
158
|
+
components: { Edit: DefaultHeadingEdit, Preview: DefaultHeadingPreview, Icon: HeadingIcon }
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
type: 'list',
|
|
162
|
+
name: 'List',
|
|
163
|
+
description: 'Bullet or numbered list',
|
|
164
|
+
icon: List,
|
|
165
|
+
category: 'text',
|
|
166
|
+
defaultData: { items: [''], type: 'ul' },
|
|
167
|
+
components: { Edit: DefaultListEdit, Preview: DefaultListPreview, Icon: List }
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
type: 'divider',
|
|
171
|
+
name: 'Divider',
|
|
172
|
+
description: 'Horizontal separator',
|
|
173
|
+
icon: Minus,
|
|
174
|
+
category: 'layout',
|
|
175
|
+
defaultData: {},
|
|
176
|
+
components: {
|
|
177
|
+
Edit: () => <hr className="my-4 border-t border-neutral-200" />,
|
|
178
|
+
Preview: () => <hr className="my-4 border-t border-neutral-200" />,
|
|
179
|
+
Icon: Minus
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
for (const def of defaults) {
|
|
185
|
+
// Use register to ensure it's also added to window
|
|
186
|
+
this.register(def);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
register(definition: BlockTypeDefinition): void {
|
|
191
|
+
this.blocks.set(definition.type, definition);
|
|
192
|
+
|
|
193
|
+
// Also attach to window for cross-context access
|
|
194
|
+
if (typeof window !== 'undefined') {
|
|
195
|
+
if (!(window as any).__JHITS_NEWSLETTER_REGISTRY__) {
|
|
196
|
+
(window as any).__JHITS_NEWSLETTER_REGISTRY__ = new Map();
|
|
197
|
+
}
|
|
198
|
+
(window as any).__JHITS_NEWSLETTER_REGISTRY__.set(definition.type, definition);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Register multiple client blocks at once
|
|
204
|
+
* This is the primary method for client applications to register their blocks
|
|
205
|
+
*/
|
|
206
|
+
registerClientBlocks(definitions: ClientBlockDefinition[]): void {
|
|
207
|
+
for (const def of definitions) {
|
|
208
|
+
if (!def.type || !def.name || !def.components) continue;
|
|
209
|
+
if (!def.components.Edit || !def.components.Preview) continue;
|
|
210
|
+
|
|
211
|
+
const blockDefinition: BlockTypeDefinition = {
|
|
212
|
+
type: def.type,
|
|
213
|
+
name: def.name,
|
|
214
|
+
description: def.description,
|
|
215
|
+
icon: def.icon || def.components.Icon,
|
|
216
|
+
defaultData: def.defaultData,
|
|
217
|
+
validate: def.validate,
|
|
218
|
+
isContainer: def.isContainer,
|
|
219
|
+
allowedChildren: def.allowedChildren,
|
|
220
|
+
category: def.category || 'custom',
|
|
221
|
+
components: def.components,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
this.register(blockDefinition);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
get(type: string): BlockTypeDefinition | undefined {
|
|
229
|
+
const block = this.blocks.get(type);
|
|
230
|
+
if (block) return block;
|
|
231
|
+
|
|
232
|
+
if (typeof window !== 'undefined' && (window as any).__JHITS_NEWSLETTER_REGISTRY__) {
|
|
233
|
+
return (window as any).__JHITS_NEWSLETTER_REGISTRY__.get(type);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
getAll(): BlockTypeDefinition[] {
|
|
240
|
+
return Array.from(this.blocks.values());
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
has(type: string): boolean {
|
|
244
|
+
return this.blocks.has(type);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
clear(): void {
|
|
248
|
+
this.blocks.clear();
|
|
249
|
+
this.registerDefaultBlocks(); // Re-add defaults after clear
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Export singleton instance
|
|
254
|
+
export const BlockRegistry = new BlockRegistryImpl();
|
|
255
|
+
export const blockRegistry = BlockRegistry;
|
|
@@ -40,8 +40,18 @@ export interface EditorProviderProps {
|
|
|
40
40
|
/** Background color for dark mode (optional) */
|
|
41
41
|
dark?: string;
|
|
42
42
|
};
|
|
43
|
+
/** Localized strings for modular blocks in the editor */
|
|
44
|
+
translations?: Record<string, any>;
|
|
43
45
|
/** If true, this editor is for the welcome email */
|
|
44
46
|
isWelcomeEmail?: boolean;
|
|
47
|
+
/** Email configuration (logo, footer, etc.) */
|
|
48
|
+
emailConfig?: {
|
|
49
|
+
logoUrl?: string;
|
|
50
|
+
logoAlt?: string;
|
|
51
|
+
footerText?: string;
|
|
52
|
+
};
|
|
53
|
+
/** Global translations for unsubscribe text */
|
|
54
|
+
unsubscribeTranslations?: Record<string, string>;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
/**
|
|
@@ -56,23 +66,44 @@ export function EditorProvider({
|
|
|
56
66
|
customBlocks = [],
|
|
57
67
|
darkMode = true,
|
|
58
68
|
backgroundColors,
|
|
59
|
-
|
|
69
|
+
translations,
|
|
70
|
+
isWelcomeEmail,
|
|
71
|
+
emailConfig,
|
|
72
|
+
unsubscribeTranslations
|
|
60
73
|
}: EditorProviderProps) {
|
|
61
|
-
|
|
74
|
+
const [state, dispatch] = useReducer(
|
|
75
|
+
editorReducer,
|
|
76
|
+
{ ...initialEditorState, ...initialState }
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Track registered blocks in state to trigger re-renders
|
|
80
|
+
const [registeredBlockTypes, setRegisteredBlockBlocks] = useState<string[]>(() =>
|
|
81
|
+
BlockRegistry.getAll().map(b => b.type)
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Register client blocks on mount or when they change
|
|
62
85
|
useEffect(() => {
|
|
63
86
|
if (customBlocks && customBlocks.length > 0) {
|
|
64
87
|
try {
|
|
65
|
-
|
|
88
|
+
BlockRegistry.registerClientBlocks(customBlocks);
|
|
89
|
+
// Update state to trigger re-render
|
|
90
|
+
setRegisteredBlockBlocks(BlockRegistry.getAll().map(b => b.type));
|
|
66
91
|
} catch (error) {
|
|
67
92
|
console.error('[NewsletterEditorContext] Failed to register custom blocks:', error);
|
|
68
93
|
}
|
|
69
94
|
}
|
|
70
95
|
}, [customBlocks]);
|
|
71
96
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
97
|
+
// Periodically check for new blocks in global registry (fallback for late loads)
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const interval = setInterval(() => {
|
|
100
|
+
const currentTypes = BlockRegistry.getAll().map(b => b.type);
|
|
101
|
+
if (currentTypes.length !== registeredBlockTypes.length) {
|
|
102
|
+
setRegisteredBlockBlocks(currentTypes);
|
|
103
|
+
}
|
|
104
|
+
}, 1000);
|
|
105
|
+
return () => clearInterval(interval);
|
|
106
|
+
}, [registeredBlockTypes.length]);
|
|
76
107
|
|
|
77
108
|
// Use a ref to always have access to the latest state in callbacks
|
|
78
109
|
const stateRef = useRef(state);
|
|
@@ -244,6 +275,10 @@ export function EditorProvider({
|
|
|
244
275
|
dispatch,
|
|
245
276
|
darkMode,
|
|
246
277
|
backgroundColors,
|
|
278
|
+
translations,
|
|
279
|
+
emailConfig,
|
|
280
|
+
unsubscribeTranslations,
|
|
281
|
+
registeredBlockTypes,
|
|
247
282
|
helpers: {
|
|
248
283
|
addBlock,
|
|
249
284
|
updateBlock,
|
|
@@ -259,7 +294,7 @@ export function EditorProvider({
|
|
|
259
294
|
canUndo: historyIndex > 0 && history.length > 0,
|
|
260
295
|
canRedo: historyIndex < history.length - 1,
|
|
261
296
|
}),
|
|
262
|
-
[state, dispatch, darkMode, backgroundColors, addBlock, updateBlock, deleteBlock, duplicateBlock, moveBlock, loadNewsletter, resetEditor, save, undo, redo, historyIndex, history.length]
|
|
297
|
+
[state, dispatch, darkMode, backgroundColors, translations, emailConfig, unsubscribeTranslations, registeredBlockTypes, addBlock, updateBlock, deleteBlock, duplicateBlock, moveBlock, loadNewsletter, resetEditor, save, undo, redo, historyIndex, history.length]
|
|
263
298
|
);
|
|
264
299
|
|
|
265
300
|
return <EditorContext.Provider value={value}>{children}</EditorContext.Provider>;
|
package/src/state/types.ts
CHANGED
|
@@ -90,6 +90,22 @@ export interface EditorContextValue {
|
|
|
90
90
|
dark?: string;
|
|
91
91
|
};
|
|
92
92
|
|
|
93
|
+
/** Localized strings for modular blocks in the editor */
|
|
94
|
+
translations?: Record<string, any>;
|
|
95
|
+
|
|
96
|
+
/** Email configuration (logo, footer, etc.) */
|
|
97
|
+
emailConfig?: {
|
|
98
|
+
logoUrl?: string;
|
|
99
|
+
logoAlt?: string;
|
|
100
|
+
footerText?: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Global translations for unsubscribe text */
|
|
104
|
+
unsubscribeTranslations?: Record<string, string>;
|
|
105
|
+
|
|
106
|
+
/** List of registered block types (used to trigger re-renders) */
|
|
107
|
+
registeredBlockTypes: string[];
|
|
108
|
+
|
|
93
109
|
/** Helper functions for common operations */
|
|
94
110
|
helpers: {
|
|
95
111
|
/** Add a new block (supports nested containers via containerId) */
|
package/src/types/block.ts
CHANGED
|
@@ -70,6 +70,16 @@ export interface BlockEditProps {
|
|
|
70
70
|
|
|
71
71
|
/** Focus mode state */
|
|
72
72
|
focusMode?: boolean;
|
|
73
|
+
|
|
74
|
+
/** Additional rendering context */
|
|
75
|
+
context?: {
|
|
76
|
+
/** Site ID */
|
|
77
|
+
siteId?: string;
|
|
78
|
+
/** Locale */
|
|
79
|
+
locale?: string;
|
|
80
|
+
/** Custom render props (e.g. translations) */
|
|
81
|
+
[key: string]: unknown;
|
|
82
|
+
};
|
|
73
83
|
|
|
74
84
|
/** Child blocks (for container blocks like Section, Columns) */
|
|
75
85
|
childBlocks?: Block[];
|