@fufulog/brevomorphic-cms-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,388 @@
1
+ import React, { useRef, useState, useCallback, useEffect } from 'react';
2
+ import './BrevoWysiwyg.css';
3
+
4
+ export interface BrevoVariable {
5
+ label: string;
6
+ key: string;
7
+ }
8
+
9
+ export interface BrevoWysiwygProps {
10
+ value: string;
11
+ onChange: (html: string) => void;
12
+ variables?: BrevoVariable[];
13
+ placeholder?: string;
14
+ disabled?: boolean;
15
+ }
16
+
17
+ /**
18
+ * BrevoWysiwyg — WYSIWYG Email Template Editor (React)
19
+ *
20
+ * Drop-in React component for the brevoCMS SDK.
21
+ * Provides rich-text editing with Source toggle, formatting toolbar,
22
+ * and Brevo variable injection. Does NOT escape Twig brackets.
23
+ *
24
+ * Usage:
25
+ * <BrevoWysiwyg
26
+ * value={htmlContent}
27
+ * onChange={setHtmlContent}
28
+ * variables={[
29
+ * { label: 'Customer Name', key: 'params.name' },
30
+ * { label: 'Ticket Code', key: 'params.ticket_code' }
31
+ * ]}
32
+ * />
33
+ */
34
+ export default function BrevoWysiwyg({
35
+ value,
36
+ onChange,
37
+ variables = [],
38
+ placeholder = 'Start designing your email template...',
39
+ disabled = false,
40
+ }: BrevoWysiwygProps) {
41
+ const editorRef = useRef<HTMLDivElement>(null);
42
+ const sourceRef = useRef<HTMLTextAreaElement>(null);
43
+
44
+ const [isSourceMode, setIsSourceMode] = useState(false);
45
+ const [sourceCode, setSourceCode] = useState('');
46
+ const [showVariableMenu, setShowVariableMenu] = useState(false);
47
+ const [showHeadingMenu, setShowHeadingMenu] = useState(false);
48
+ const [isFocused, setIsFocused] = useState(false);
49
+ const [activeFormats, setActiveFormats] = useState({
50
+ bold: false,
51
+ italic: false,
52
+ underline: false,
53
+ strikeThrough: false,
54
+ insertOrderedList: false,
55
+ insertUnorderedList: false,
56
+ });
57
+
58
+ // Sync initial value into editor
59
+ useEffect(() => {
60
+ if (editorRef.current && !isSourceMode) {
61
+ if (editorRef.current.innerHTML !== value) {
62
+ editorRef.current.innerHTML = value;
63
+ }
64
+ }
65
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
66
+
67
+ // Close menus on outside click
68
+ useEffect(() => {
69
+ const handler = (e: MouseEvent) => {
70
+ if (!(e.target as HTMLElement).closest('.brevo-dropdown')) {
71
+ setShowVariableMenu(false);
72
+ setShowHeadingMenu(false);
73
+ }
74
+ };
75
+ window.addEventListener('click', handler);
76
+ return () => window.removeEventListener('click', handler);
77
+ }, []);
78
+
79
+ /** Extract raw HTML from editor — restoring any accidentally encoded Twig brackets */
80
+ const extractHtml = useCallback((): string => {
81
+ if (!editorRef.current) return '';
82
+ return editorRef.current.innerHTML
83
+ .replace(/&lbrace;/g, '{')
84
+ .replace(/&rbrace;/g, '}')
85
+ .replace(/&#123;/g, '{')
86
+ .replace(/&#125;/g, '}')
87
+ .replace(/&#37;/g, '%')
88
+ .replace(/&percnt;/g, '%');
89
+ }, []);
90
+
91
+ const syncFromEditor = useCallback(() => {
92
+ if (!editorRef.current || isSourceMode) return;
93
+ const cleaned = extractHtml();
94
+ onChange(cleaned);
95
+ }, [isSourceMode, onChange, extractHtml]);
96
+
97
+ const updateActiveFormats = useCallback(() => {
98
+ setActiveFormats({
99
+ bold: document.queryCommandState('bold'),
100
+ italic: document.queryCommandState('italic'),
101
+ underline: document.queryCommandState('underline'),
102
+ strikeThrough: document.queryCommandState('strikeThrough'),
103
+ insertOrderedList: document.queryCommandState('insertOrderedList'),
104
+ insertUnorderedList: document.queryCommandState('insertUnorderedList'),
105
+ });
106
+ }, []);
107
+
108
+ const execCmd = useCallback(
109
+ (command: string, val: string | null = null) => {
110
+ if (isSourceMode || disabled) return;
111
+ editorRef.current?.focus();
112
+ document.execCommand(command, false, val ?? undefined);
113
+ syncFromEditor();
114
+ updateActiveFormats();
115
+ },
116
+ [isSourceMode, disabled, syncFromEditor, updateActiveFormats]
117
+ );
118
+
119
+ const toggleSourceMode = useCallback(() => {
120
+ if (isSourceMode) {
121
+ // Source → Visual
122
+ onChange(sourceCode);
123
+ setIsSourceMode(false);
124
+ setTimeout(() => {
125
+ if (editorRef.current) editorRef.current.innerHTML = sourceCode;
126
+ }, 0);
127
+ } else {
128
+ // Visual → Source
129
+ const html = extractHtml();
130
+ setSourceCode(html);
131
+ onChange(html);
132
+ setIsSourceMode(true);
133
+ }
134
+ setShowVariableMenu(false);
135
+ setShowHeadingMenu(false);
136
+ }, [isSourceMode, sourceCode, onChange, extractHtml]);
137
+
138
+ const insertLink = useCallback(() => {
139
+ if (isSourceMode || disabled) return;
140
+ const url = prompt('Enter URL:');
141
+ if (url) execCmd('createLink', url);
142
+ }, [isSourceMode, disabled, execCmd]);
143
+
144
+ const insertVariable = useCallback(
145
+ (variable: BrevoVariable) => {
146
+ if (disabled) return;
147
+ const twigExpr = `{{ ${variable.key} }}`;
148
+
149
+ if (isSourceMode && sourceRef.current) {
150
+ const el = sourceRef.current;
151
+ const start = el.selectionStart;
152
+ const end = el.selectionEnd;
153
+ const updated = sourceCode.substring(0, start) + twigExpr + sourceCode.substring(end);
154
+ setSourceCode(updated);
155
+ onChange(updated);
156
+ setTimeout(() => {
157
+ el.selectionStart = el.selectionEnd = start + twigExpr.length;
158
+ el.focus();
159
+ }, 0);
160
+ } else if (editorRef.current) {
161
+ editorRef.current.focus();
162
+ document.execCommand('insertHTML', false, twigExpr);
163
+ syncFromEditor();
164
+ }
165
+
166
+ setShowVariableMenu(false);
167
+ },
168
+ [disabled, isSourceMode, sourceCode, onChange, syncFromEditor]
169
+ );
170
+
171
+ const handleSourceChange = useCallback(
172
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
173
+ const val = e.target.value;
174
+ setSourceCode(val);
175
+ onChange(val);
176
+ },
177
+ [onChange]
178
+ );
179
+
180
+ const wrapperClasses = [
181
+ 'brevo-wysiwyg',
182
+ disabled && 'brevo-wysiwyg--disabled',
183
+ isFocused && 'brevo-wysiwyg--focused',
184
+ ]
185
+ .filter(Boolean)
186
+ .join(' ');
187
+
188
+ return (
189
+ <div className={wrapperClasses}>
190
+ {/* ── Toolbar ── */}
191
+ <div className="brevo-toolbar">
192
+ <div className="brevo-toolbar__group">
193
+ <button
194
+ type="button"
195
+ className={`brevo-btn ${activeFormats.bold ? 'brevo-btn--active' : ''}`}
196
+ title="Bold"
197
+ onClick={() => execCmd('bold')}
198
+ disabled={disabled}
199
+ >
200
+ <strong>B</strong>
201
+ </button>
202
+ <button
203
+ type="button"
204
+ className={`brevo-btn ${activeFormats.italic ? 'brevo-btn--active' : ''}`}
205
+ title="Italic"
206
+ onClick={() => execCmd('italic')}
207
+ disabled={disabled}
208
+ >
209
+ <em>I</em>
210
+ </button>
211
+ <button
212
+ type="button"
213
+ className={`brevo-btn ${activeFormats.underline ? 'brevo-btn--active' : ''}`}
214
+ title="Underline"
215
+ onClick={() => execCmd('underline')}
216
+ disabled={disabled}
217
+ >
218
+ <u>U</u>
219
+ </button>
220
+ <button
221
+ type="button"
222
+ className={`brevo-btn ${activeFormats.strikeThrough ? 'brevo-btn--active' : ''}`}
223
+ title="Strikethrough"
224
+ onClick={() => execCmd('strikeThrough')}
225
+ disabled={disabled}
226
+ >
227
+ <s>S</s>
228
+ </button>
229
+ </div>
230
+
231
+ <div className="brevo-toolbar__divider" />
232
+
233
+ {/* Heading dropdown */}
234
+ <div className="brevo-dropdown">
235
+ <button
236
+ type="button"
237
+ className="brevo-btn"
238
+ title="Headings"
239
+ onClick={(e) => {
240
+ e.stopPropagation();
241
+ setShowHeadingMenu((v) => !v);
242
+ setShowVariableMenu(false);
243
+ }}
244
+ disabled={disabled}
245
+ >
246
+ H ▾
247
+ </button>
248
+ {showHeadingMenu && (
249
+ <div className="brevo-dropdown__menu">
250
+ <button type="button" onClick={() => { execCmd('formatBlock', 'h1'); setShowHeadingMenu(false); }}>
251
+ Heading 1
252
+ </button>
253
+ <button type="button" onClick={() => { execCmd('formatBlock', 'h2'); setShowHeadingMenu(false); }}>
254
+ Heading 2
255
+ </button>
256
+ <button type="button" onClick={() => { execCmd('formatBlock', 'h3'); setShowHeadingMenu(false); }}>
257
+ Heading 3
258
+ </button>
259
+ <button type="button" onClick={() => { execCmd('formatBlock', 'p'); setShowHeadingMenu(false); }}>
260
+ Paragraph
261
+ </button>
262
+ </div>
263
+ )}
264
+ </div>
265
+
266
+ <div className="brevo-toolbar__divider" />
267
+
268
+ <div className="brevo-toolbar__group">
269
+ <button
270
+ type="button"
271
+ className={`brevo-btn ${activeFormats.insertUnorderedList ? 'brevo-btn--active' : ''}`}
272
+ title="Bullet List"
273
+ onClick={() => execCmd('insertUnorderedList')}
274
+ disabled={disabled}
275
+ >
276
+ • List
277
+ </button>
278
+ <button
279
+ type="button"
280
+ className={`brevo-btn ${activeFormats.insertOrderedList ? 'brevo-btn--active' : ''}`}
281
+ title="Numbered List"
282
+ onClick={() => execCmd('insertOrderedList')}
283
+ disabled={disabled}
284
+ >
285
+ 1. List
286
+ </button>
287
+ </div>
288
+
289
+ <div className="brevo-toolbar__divider" />
290
+
291
+ <div className="brevo-toolbar__group">
292
+ <button type="button" className="brevo-btn" title="Align Left" onClick={() => execCmd('justifyLeft')} disabled={disabled}>
293
+
294
+ </button>
295
+ <button type="button" className="brevo-btn" title="Align Center" onClick={() => execCmd('justifyCenter')} disabled={disabled}>
296
+ ⫿
297
+ </button>
298
+ <button type="button" className="brevo-btn" title="Align Right" onClick={() => execCmd('justifyRight')} disabled={disabled}>
299
+
300
+ </button>
301
+ </div>
302
+
303
+ <div className="brevo-toolbar__divider" />
304
+
305
+ <button type="button" className="brevo-btn" title="Insert Link" onClick={insertLink} disabled={disabled}>
306
+ 🔗
307
+ </button>
308
+
309
+ {/* Variable injector */}
310
+ {variables.length > 0 && (
311
+ <>
312
+ <div className="brevo-toolbar__divider" />
313
+ <div className="brevo-dropdown">
314
+ <button
315
+ type="button"
316
+ className="brevo-btn brevo-btn--accent"
317
+ title="Insert Variable"
318
+ onClick={(e) => {
319
+ e.stopPropagation();
320
+ setShowVariableMenu((v) => !v);
321
+ setShowHeadingMenu(false);
322
+ }}
323
+ disabled={disabled}
324
+ >
325
+ {'{{ }}'} Insert Variable ▾
326
+ </button>
327
+ {showVariableMenu && (
328
+ <div className="brevo-dropdown__menu brevo-dropdown__menu--variables">
329
+ {variables.map((v) => (
330
+ <button type="button" key={v.key} onClick={() => insertVariable(v)}>
331
+ <span className="brevo-var-label">{v.label}</span>
332
+ <code className="brevo-var-key">{`{{ ${v.key} }}`}</code>
333
+ </button>
334
+ ))}
335
+ </div>
336
+ )}
337
+ </div>
338
+ </>
339
+ )}
340
+
341
+ {/* Source toggle */}
342
+ <div className="brevo-toolbar__spacer" />
343
+ <button
344
+ type="button"
345
+ className={`brevo-btn brevo-btn--source ${isSourceMode ? 'brevo-btn--active' : ''}`}
346
+ title="Toggle Source View"
347
+ onClick={toggleSourceMode}
348
+ disabled={disabled}
349
+ >
350
+ {isSourceMode ? '✎ Visual' : '</> Source'}
351
+ </button>
352
+ </div>
353
+
354
+ {/* ── Editor Area ── */}
355
+ <div className="brevo-editor-wrapper">
356
+ {isSourceMode ? (
357
+ <textarea
358
+ ref={sourceRef}
359
+ className="brevo-source"
360
+ value={sourceCode}
361
+ onChange={handleSourceChange}
362
+ onFocus={() => setIsFocused(true)}
363
+ onBlur={() => setIsFocused(false)}
364
+ placeholder={placeholder}
365
+ disabled={disabled}
366
+ spellCheck={false}
367
+ />
368
+ ) : (
369
+ <div
370
+ ref={editorRef}
371
+ className="brevo-editor"
372
+ contentEditable={!disabled}
373
+ role="textbox"
374
+ aria-multiline={true}
375
+ aria-label="Email template editor"
376
+ data-placeholder={placeholder}
377
+ onInput={() => { syncFromEditor(); updateActiveFormats(); }}
378
+ onKeyUp={updateActiveFormats}
379
+ onMouseUp={updateActiveFormats}
380
+ onFocus={() => setIsFocused(true)}
381
+ onBlur={() => setIsFocused(false)}
382
+ suppressContentEditableWarning
383
+ />
384
+ )}
385
+ </div>
386
+ </div>
387
+ );
388
+ }
@@ -0,0 +1,171 @@
1
+ import axios, { AxiosInstance } from 'axios';
2
+ import { BrevoTemplate, BrevoSender } from './types.js';
3
+
4
+ export class BrevoClient {
5
+ private client: AxiosInstance;
6
+
7
+ constructor(apiKey: string) {
8
+ if (!apiKey) {
9
+ throw new Error('Brevo API key is required');
10
+ }
11
+ this.client = axios.create({
12
+ baseURL: 'https://api.brevo.com/v3',
13
+ headers: {
14
+ 'api-key': apiKey,
15
+ 'Content-Type': 'application/json',
16
+ 'Accept': 'application/json',
17
+ },
18
+ });
19
+ }
20
+
21
+ /**
22
+ * Fetch all templates from Brevo.
23
+ * By default, limits templates but we can fetch them all.
24
+ */
25
+ async getTemplates(limit = 100, offset = 0): Promise<BrevoTemplate[]> {
26
+ try {
27
+ const response = await this.client.get<{ count: number; templates?: any[] }>(
28
+ `/smtp/templates?limit=${limit}&offset=${offset}`
29
+ );
30
+
31
+ const templates = response.data.templates || [];
32
+ return templates.map((t: any) => ({
33
+ id: t.id,
34
+ name: t.name,
35
+ subject: t.subject,
36
+ isActive: t.isActive,
37
+ htmlContent: t.htmlContent,
38
+ sender: t.sender ? { id: t.sender.id, name: t.sender.name, email: t.sender.email } : undefined,
39
+ }));
40
+ } catch (error: any) {
41
+ const msg = error.response?.data?.message || error.message;
42
+ throw new Error(`Failed to fetch templates from Brevo: ${msg}`);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Fetch a single template by its Brevo ID
48
+ */
49
+ async getTemplate(id: number): Promise<BrevoTemplate> {
50
+ try {
51
+ const response = await this.client.get<any>(`/smtp/templates/${id}`);
52
+ const t = response.data;
53
+ return {
54
+ id: t.id,
55
+ name: t.name,
56
+ subject: t.subject,
57
+ isActive: t.isActive,
58
+ htmlContent: t.htmlContent,
59
+ sender: t.sender ? { id: t.sender.id, name: t.sender.name, email: t.sender.email } : undefined,
60
+ };
61
+ } catch (error: any) {
62
+ const msg = error.response?.data?.message || error.message;
63
+ throw new Error(`Failed to fetch template #${id} from Brevo: ${msg}`);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Create a new SMTP template on Brevo
69
+ */
70
+ async createTemplate(params: {
71
+ templateName: string;
72
+ subject: string;
73
+ sender: { name?: string; email: string };
74
+ htmlContent: string;
75
+ isActive?: boolean;
76
+ }): Promise<number> {
77
+ try {
78
+ const response = await this.client.post<{ id: number }>(
79
+ '/smtp/templates',
80
+ {
81
+ templateName: params.templateName,
82
+ subject: params.subject,
83
+ sender: params.sender,
84
+ htmlContent: params.htmlContent,
85
+ isActive: params.isActive ?? false,
86
+ }
87
+ );
88
+ return response.data.id;
89
+ } catch (error: any) {
90
+ const msg = error.response?.data?.message || error.message;
91
+ throw new Error(`Failed to create template on Brevo: ${msg}`);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Update an SMTP template on Brevo
97
+ */
98
+ async updateTemplate(
99
+ id: number,
100
+ params: {
101
+ templateName: string;
102
+ subject: string;
103
+ sender: { name?: string; email: string; id?: number };
104
+ htmlContent: string;
105
+ isActive: boolean;
106
+ }
107
+ ): Promise<void> {
108
+ try {
109
+ // Brevo PUT updates require sending sender as object { name, email } (or sender ID)
110
+ const payload: any = {
111
+ templateName: params.templateName,
112
+ subject: params.subject,
113
+ htmlContent: params.htmlContent,
114
+ isActive: params.isActive,
115
+ };
116
+
117
+ if (params.sender) {
118
+ payload.sender = {
119
+ email: params.sender.email,
120
+ };
121
+ if (params.sender.name) {
122
+ payload.sender.name = params.sender.name;
123
+ }
124
+ }
125
+
126
+ await this.client.put(`/smtp/templates/${id}`, payload);
127
+ } catch (error: any) {
128
+ const msg = error.response?.data?.message || error.message;
129
+ throw new Error(`Failed to update template #${id} on Brevo: ${msg}`);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Get all active and verified sender profiles from Brevo
135
+ */
136
+ async getSenders(): Promise<BrevoSender[]> {
137
+ try {
138
+ const response = await this.client.get<{ senders?: any[] }>('/senders');
139
+ const senders = response.data.senders || [];
140
+ return senders.map((s: any) => ({
141
+ id: s.id,
142
+ name: s.name,
143
+ email: s.email,
144
+ active: s.active,
145
+ }));
146
+ } catch (error: any) {
147
+ const msg = error.response?.data?.message || error.message;
148
+ throw new Error(`Failed to fetch senders from Brevo: ${msg}`);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Send a transactional template email via Brevo SMTP API
154
+ */
155
+ async sendEmail(params: {
156
+ templateId: number;
157
+ to: string;
158
+ variables?: Record<string, any>;
159
+ }): Promise<void> {
160
+ try {
161
+ await this.client.post('/smtp/email', {
162
+ templateId: params.templateId,
163
+ to: [{ email: params.to }],
164
+ params: params.variables || {},
165
+ });
166
+ } catch (error: any) {
167
+ const msg = error.response?.data?.message || error.message;
168
+ throw new Error(`Failed to send event email via Brevo: ${msg}`);
169
+ }
170
+ }
171
+ }