@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.
package/src/types.ts ADDED
@@ -0,0 +1,77 @@
1
+ export interface BrevoSender {
2
+ id?: number;
3
+ name: string;
4
+ email: string;
5
+ active?: boolean;
6
+ }
7
+
8
+ export interface BrevoTemplate {
9
+ id: number;
10
+ name: string;
11
+ subject: string;
12
+ isActive: boolean;
13
+ htmlContent?: string;
14
+ sender?: BrevoSender;
15
+ }
16
+
17
+ export interface LocalMapping {
18
+ id?: number | string;
19
+ template_id: number;
20
+ event_name: string;
21
+ is_active: boolean;
22
+ updated_at?: Date | string;
23
+ }
24
+
25
+ export interface CombinedTemplate {
26
+ templateId: number;
27
+ templateName: string;
28
+ subject: string;
29
+ eventName: string;
30
+ isActive: boolean; // synced from local state & remote
31
+ sender?: BrevoSender;
32
+ htmlContent?: string;
33
+ }
34
+
35
+ export interface SQLClient {
36
+ query: (sql: string, params: any[]) => Promise<any>;
37
+ }
38
+
39
+ export interface FirestoreClient {
40
+ collection: (collectionPath: string) => any;
41
+ }
42
+
43
+ export interface SDKConfig {
44
+ brevoApiKey: string;
45
+ defaultSender: {
46
+ name?: string;
47
+ email: string;
48
+ };
49
+ dbType: 'postgres' | 'mysql' | 'firestore';
50
+ /**
51
+ * For 'postgres': an object satisfying { query: (sql, params) => Promise<{ rows: any[] }> } or equivalent
52
+ * For 'mysql': an object satisfying { query: (sql, params) => Promise<[any[], any]> } or equivalent
53
+ * For 'firestore': an instance of Firebase Firestore DB
54
+ */
55
+ dbClient: any;
56
+ /**
57
+ * Table name for postgres/mysql, or Collection path for Firestore.
58
+ * Defaults to 'email_event_templates'
59
+ */
60
+ tableNameOrCollection?: string;
61
+ }
62
+
63
+ export interface CreateTemplateInput {
64
+ name?: string;
65
+ subject?: string;
66
+ senderEmail?: string;
67
+ senderName?: string;
68
+ }
69
+
70
+ export interface UpdateTemplateInput {
71
+ templateName: string;
72
+ subject: string;
73
+ sender: BrevoSender;
74
+ htmlContent: string;
75
+ eventName: string;
76
+ isActive: boolean;
77
+ }
@@ -0,0 +1,572 @@
1
+ <!--
2
+ BrevoWysiwyg.svelte — WYSIWYG Email Template Editor
3
+
4
+ Drop-in Svelte component for the brevoCMS SDK.
5
+ Provides rich-text editing with Source toggle, formatting toolbar,
6
+ and Brevo variable injection. Does NOT escape Twig brackets.
7
+
8
+ Props:
9
+ value (string) — HTML content (bind:value for two-way binding)
10
+ variables (array) — [{ label: string, key: string }] for variable injection
11
+ placeholder (string) — Placeholder text when editor is empty
12
+ disabled (boolean) — Disables the editor
13
+
14
+ Events:
15
+ on:change — Fires with { detail: string } containing raw HTML
16
+
17
+ Usage:
18
+ <BrevoWysiwyg
19
+ bind:value={htmlContent}
20
+ variables={[
21
+ { label: 'Customer Name', key: 'params.name' },
22
+ { label: 'Ticket Code', key: 'params.ticket_code' }
23
+ ]}
24
+ on:change={(e) => console.log(e.detail)}
25
+ />
26
+ -->
27
+
28
+ <script>
29
+ import { onMount, createEventDispatcher, tick } from 'svelte';
30
+
31
+ export let value = '';
32
+ export let variables = [];
33
+ export let placeholder = 'Start designing your email template...';
34
+ export let disabled = false;
35
+
36
+ const dispatch = createEventDispatcher();
37
+
38
+ let editorEl;
39
+ let sourceEl;
40
+ let isSourceMode = false;
41
+ let sourceCode = '';
42
+ let showVariableMenu = false;
43
+ let showHeadingMenu = false;
44
+ let isFocused = false;
45
+
46
+ // Toolbar state tracking
47
+ let activeFormats = {
48
+ bold: false,
49
+ italic: false,
50
+ underline: false,
51
+ strikeThrough: false,
52
+ insertOrderedList: false,
53
+ insertUnorderedList: false,
54
+ };
55
+
56
+ onMount(() => {
57
+ if (value && editorEl) {
58
+ editorEl.innerHTML = value;
59
+ }
60
+ });
61
+
62
+ function syncFromEditor() {
63
+ if (!editorEl || isSourceMode) return;
64
+ // Extract raw innerHTML — NO sanitization of { } % characters
65
+ const raw = editorEl.innerHTML;
66
+ // Restore any accidentally encoded Twig brackets
67
+ const cleaned = raw
68
+ .replace(/&lbrace;/g, '{')
69
+ .replace(/&rbrace;/g, '}')
70
+ .replace(/&#123;/g, '{')
71
+ .replace(/&#125;/g, '}')
72
+ .replace(/&#37;/g, '%')
73
+ .replace(/&percnt;/g, '%');
74
+ value = cleaned;
75
+ dispatch('change', cleaned);
76
+ }
77
+
78
+ function syncFromSource() {
79
+ sourceCode = sourceEl?.value ?? sourceCode;
80
+ value = sourceCode;
81
+ dispatch('change', value);
82
+ }
83
+
84
+ async function toggleSourceMode() {
85
+ if (isSourceMode) {
86
+ // Switching to Visual — load source into editor
87
+ value = sourceCode;
88
+ isSourceMode = false;
89
+ await tick();
90
+ if (editorEl) editorEl.innerHTML = value;
91
+ } else {
92
+ // Switching to Source — serialize editor to textarea
93
+ syncFromEditor();
94
+ sourceCode = value;
95
+ isSourceMode = true;
96
+ }
97
+ showVariableMenu = false;
98
+ showHeadingMenu = false;
99
+ }
100
+
101
+ function execCmd(command, val = null) {
102
+ if (isSourceMode || disabled) return;
103
+ editorEl?.focus();
104
+ document.execCommand(command, false, val);
105
+ syncFromEditor();
106
+ updateActiveFormats();
107
+ }
108
+
109
+ function updateActiveFormats() {
110
+ activeFormats = {
111
+ bold: document.queryCommandState('bold'),
112
+ italic: document.queryCommandState('italic'),
113
+ underline: document.queryCommandState('underline'),
114
+ strikeThrough: document.queryCommandState('strikeThrough'),
115
+ insertOrderedList: document.queryCommandState('insertOrderedList'),
116
+ insertUnorderedList: document.queryCommandState('insertUnorderedList'),
117
+ };
118
+ }
119
+
120
+ function insertHeading(tag) {
121
+ execCmd('formatBlock', tag);
122
+ showHeadingMenu = false;
123
+ }
124
+
125
+ function insertLink() {
126
+ if (isSourceMode || disabled) return;
127
+ const url = prompt('Enter URL:');
128
+ if (url) execCmd('createLink', url);
129
+ }
130
+
131
+ function insertVariable(variable) {
132
+ if (disabled) return;
133
+ const twigExpr = `{{ ${variable.key} }}`;
134
+
135
+ if (isSourceMode && sourceEl) {
136
+ // Insert into textarea at cursor position
137
+ const start = sourceEl.selectionStart;
138
+ const end = sourceEl.selectionEnd;
139
+ const before = sourceCode.substring(0, start);
140
+ const after = sourceCode.substring(end);
141
+ sourceCode = before + twigExpr + after;
142
+ sourceEl.value = sourceCode;
143
+ value = sourceCode;
144
+ dispatch('change', value);
145
+ // Restore cursor after inserted text
146
+ tick().then(() => {
147
+ sourceEl.selectionStart = sourceEl.selectionEnd = start + twigExpr.length;
148
+ sourceEl.focus();
149
+ });
150
+ } else if (editorEl) {
151
+ // Insert into contentEditable at cursor position
152
+ editorEl.focus();
153
+ // Use insertHTML to preserve Twig brackets exactly
154
+ document.execCommand('insertHTML', false, twigExpr);
155
+ syncFromEditor();
156
+ }
157
+
158
+ showVariableMenu = false;
159
+ }
160
+
161
+ function handleEditorInput() {
162
+ syncFromEditor();
163
+ updateActiveFormats();
164
+ }
165
+
166
+ function handleEditorKeyup() {
167
+ updateActiveFormats();
168
+ }
169
+
170
+ function handleEditorMouseup() {
171
+ updateActiveFormats();
172
+ }
173
+
174
+ function closeMenus(e) {
175
+ if (!e.target.closest('.brevo-dropdown')) {
176
+ showVariableMenu = false;
177
+ showHeadingMenu = false;
178
+ }
179
+ }
180
+ </script>
181
+
182
+ <svelte:window on:click={closeMenus} />
183
+
184
+ <div class="brevo-wysiwyg" class:brevo-wysiwyg--disabled={disabled} class:brevo-wysiwyg--focused={isFocused}>
185
+ <!-- Toolbar -->
186
+ <div class="brevo-toolbar">
187
+ <div class="brevo-toolbar__group">
188
+ <button
189
+ type="button"
190
+ class="brevo-btn"
191
+ class:brevo-btn--active={activeFormats.bold}
192
+ title="Bold"
193
+ on:click={() => execCmd('bold')}
194
+ {disabled}
195
+ ><strong>B</strong></button>
196
+
197
+ <button
198
+ type="button"
199
+ class="brevo-btn"
200
+ class:brevo-btn--active={activeFormats.italic}
201
+ title="Italic"
202
+ on:click={() => execCmd('italic')}
203
+ {disabled}
204
+ ><em>I</em></button>
205
+
206
+ <button
207
+ type="button"
208
+ class="brevo-btn"
209
+ class:brevo-btn--active={activeFormats.underline}
210
+ title="Underline"
211
+ on:click={() => execCmd('underline')}
212
+ {disabled}
213
+ ><u>U</u></button>
214
+
215
+ <button
216
+ type="button"
217
+ class="brevo-btn"
218
+ class:brevo-btn--active={activeFormats.strikeThrough}
219
+ title="Strikethrough"
220
+ on:click={() => execCmd('strikeThrough')}
221
+ {disabled}
222
+ ><s>S</s></button>
223
+ </div>
224
+
225
+ <div class="brevo-toolbar__divider"></div>
226
+
227
+ <!-- Heading dropdown -->
228
+ <div class="brevo-dropdown">
229
+ <button
230
+ type="button"
231
+ class="brevo-btn"
232
+ title="Headings"
233
+ on:click|stopPropagation={() => { showHeadingMenu = !showHeadingMenu; showVariableMenu = false; }}
234
+ {disabled}
235
+ >H ▾</button>
236
+
237
+ {#if showHeadingMenu}
238
+ <div class="brevo-dropdown__menu">
239
+ <button type="button" on:click={() => insertHeading('h1')}>Heading 1</button>
240
+ <button type="button" on:click={() => insertHeading('h2')}>Heading 2</button>
241
+ <button type="button" on:click={() => insertHeading('h3')}>Heading 3</button>
242
+ <button type="button" on:click={() => insertHeading('p')}>Paragraph</button>
243
+ </div>
244
+ {/if}
245
+ </div>
246
+
247
+ <div class="brevo-toolbar__divider"></div>
248
+
249
+ <div class="brevo-toolbar__group">
250
+ <button
251
+ type="button"
252
+ class="brevo-btn"
253
+ class:brevo-btn--active={activeFormats.insertUnorderedList}
254
+ title="Bullet List"
255
+ on:click={() => execCmd('insertUnorderedList')}
256
+ {disabled}
257
+ >• List</button>
258
+
259
+ <button
260
+ type="button"
261
+ class="brevo-btn"
262
+ class:brevo-btn--active={activeFormats.insertOrderedList}
263
+ title="Numbered List"
264
+ on:click={() => execCmd('insertOrderedList')}
265
+ {disabled}
266
+ >1. List</button>
267
+ </div>
268
+
269
+ <div class="brevo-toolbar__divider"></div>
270
+
271
+ <div class="brevo-toolbar__group">
272
+ <button type="button" class="brevo-btn" title="Align Left" on:click={() => execCmd('justifyLeft')} {disabled}>⫷</button>
273
+ <button type="button" class="brevo-btn" title="Align Center" on:click={() => execCmd('justifyCenter')} {disabled}>⫿</button>
274
+ <button type="button" class="brevo-btn" title="Align Right" on:click={() => execCmd('justifyRight')} {disabled}>⫸</button>
275
+ </div>
276
+
277
+ <div class="brevo-toolbar__divider"></div>
278
+
279
+ <button type="button" class="brevo-btn" title="Insert Link" on:click={insertLink} {disabled}>🔗</button>
280
+
281
+ <!-- Variable injector -->
282
+ {#if variables.length > 0}
283
+ <div class="brevo-toolbar__divider"></div>
284
+ <div class="brevo-dropdown">
285
+ <button
286
+ type="button"
287
+ class="brevo-btn brevo-btn--accent"
288
+ title="Insert Variable"
289
+ on:click|stopPropagation={() => { showVariableMenu = !showVariableMenu; showHeadingMenu = false; }}
290
+ {disabled}
291
+ >&#123;&#123; &#125;&#125; Insert Variable ▾</button>
292
+
293
+ {#if showVariableMenu}
294
+ <div class="brevo-dropdown__menu brevo-dropdown__menu--variables">
295
+ {#each variables as variable}
296
+ <button type="button" on:click={() => insertVariable(variable)}>
297
+ <span class="brevo-var-label">{variable.label}</span>
298
+ <code class="brevo-var-key">{'{{ ' + variable.key + ' }}'}</code>
299
+ </button>
300
+ {/each}
301
+ </div>
302
+ {/if}
303
+ </div>
304
+ {/if}
305
+
306
+ <!-- Source toggle (right-aligned) -->
307
+ <div class="brevo-toolbar__spacer"></div>
308
+ <button
309
+ type="button"
310
+ class="brevo-btn brevo-btn--source"
311
+ class:brevo-btn--active={isSourceMode}
312
+ title="Toggle Source View"
313
+ on:click={toggleSourceMode}
314
+ {disabled}
315
+ >{isSourceMode ? '✎ Visual' : '&lt;/&gt; Source'}</button>
316
+ </div>
317
+
318
+ <!-- Editor Area -->
319
+ <div class="brevo-editor-wrapper">
320
+ {#if isSourceMode}
321
+ <textarea
322
+ bind:this={sourceEl}
323
+ bind:value={sourceCode}
324
+ class="brevo-source"
325
+ on:input={syncFromSource}
326
+ on:focus={() => (isFocused = true)}
327
+ on:blur={() => (isFocused = false)}
328
+ {placeholder}
329
+ {disabled}
330
+ spellcheck="false"
331
+ ></textarea>
332
+ {:else}
333
+ <div
334
+ bind:this={editorEl}
335
+ class="brevo-editor"
336
+ contenteditable={!disabled}
337
+ role="textbox"
338
+ tabindex="0"
339
+ aria-multiline="true"
340
+ aria-label="Email template editor"
341
+ data-placeholder={placeholder}
342
+ on:input={handleEditorInput}
343
+ on:keyup={handleEditorKeyup}
344
+ on:mouseup={handleEditorMouseup}
345
+ on:focus={() => (isFocused = true)}
346
+ on:blur={() => (isFocused = false)}
347
+ ></div>
348
+ {/if}
349
+ </div>
350
+ </div>
351
+
352
+ <style>
353
+ /* ── Container ── */
354
+ .brevo-wysiwyg {
355
+ --brevo-bg: #1a1a2e;
356
+ --brevo-surface: #16213e;
357
+ --brevo-border: #2a2a4a;
358
+ --brevo-border-focus: #6366f1;
359
+ --brevo-text: #e2e8f0;
360
+ --brevo-text-muted: #94a3b8;
361
+ --brevo-accent: #6366f1;
362
+ --brevo-accent-hover: #818cf8;
363
+ --brevo-toolbar-bg: #0f1629;
364
+ --brevo-btn-hover: #2a2a4a;
365
+ --brevo-btn-active: #3730a3;
366
+ --brevo-radius: 8px;
367
+ --brevo-font: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
368
+
369
+ font-family: var(--brevo-font);
370
+ border: 1.5px solid var(--brevo-border);
371
+ border-radius: var(--brevo-radius);
372
+ overflow: hidden;
373
+ background: var(--brevo-bg);
374
+ transition: border-color 0.2s ease;
375
+ }
376
+
377
+ .brevo-wysiwyg--focused {
378
+ border-color: var(--brevo-border-focus);
379
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
380
+ }
381
+
382
+ .brevo-wysiwyg--disabled {
383
+ opacity: 0.55;
384
+ pointer-events: none;
385
+ }
386
+
387
+ /* ── Toolbar ── */
388
+ .brevo-toolbar {
389
+ display: flex;
390
+ align-items: center;
391
+ flex-wrap: wrap;
392
+ gap: 2px;
393
+ padding: 6px 8px;
394
+ background: var(--brevo-toolbar-bg);
395
+ border-bottom: 1px solid var(--brevo-border);
396
+ }
397
+
398
+ .brevo-toolbar__group {
399
+ display: flex;
400
+ gap: 2px;
401
+ }
402
+
403
+ .brevo-toolbar__divider {
404
+ width: 1px;
405
+ height: 22px;
406
+ background: var(--brevo-border);
407
+ margin: 0 4px;
408
+ }
409
+
410
+ .brevo-toolbar__spacer {
411
+ flex: 1;
412
+ }
413
+
414
+ /* ── Buttons ── */
415
+ .brevo-btn {
416
+ display: inline-flex;
417
+ align-items: center;
418
+ gap: 4px;
419
+ padding: 5px 10px;
420
+ border: none;
421
+ border-radius: 5px;
422
+ background: transparent;
423
+ color: var(--brevo-text-muted);
424
+ font-family: var(--brevo-font);
425
+ font-size: 13px;
426
+ cursor: pointer;
427
+ transition: all 0.15s ease;
428
+ white-space: nowrap;
429
+ line-height: 1.4;
430
+ }
431
+
432
+ .brevo-btn:hover {
433
+ background: var(--brevo-btn-hover);
434
+ color: var(--brevo-text);
435
+ }
436
+
437
+ .brevo-btn--active {
438
+ background: var(--brevo-btn-active);
439
+ color: #fff;
440
+ }
441
+
442
+ .brevo-btn--accent {
443
+ color: var(--brevo-accent);
444
+ font-weight: 500;
445
+ }
446
+
447
+ .brevo-btn--accent:hover {
448
+ color: var(--brevo-accent-hover);
449
+ background: rgba(99, 102, 241, 0.12);
450
+ }
451
+
452
+ .brevo-btn--source {
453
+ font-size: 12px;
454
+ font-weight: 600;
455
+ letter-spacing: 0.02em;
456
+ }
457
+
458
+ /* ── Dropdowns ── */
459
+ .brevo-dropdown {
460
+ position: relative;
461
+ }
462
+
463
+ .brevo-dropdown__menu {
464
+ position: absolute;
465
+ top: calc(100% + 4px);
466
+ left: 0;
467
+ min-width: 160px;
468
+ padding: 4px;
469
+ background: var(--brevo-surface);
470
+ border: 1px solid var(--brevo-border);
471
+ border-radius: 6px;
472
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
473
+ z-index: 100;
474
+ animation: brevo-dropdown-in 0.12s ease-out;
475
+ }
476
+
477
+ .brevo-dropdown__menu--variables {
478
+ min-width: 260px;
479
+ }
480
+
481
+ @keyframes brevo-dropdown-in {
482
+ from { opacity: 0; transform: translateY(-4px); }
483
+ to { opacity: 1; transform: translateY(0); }
484
+ }
485
+
486
+ .brevo-dropdown__menu button {
487
+ display: flex;
488
+ align-items: center;
489
+ justify-content: space-between;
490
+ width: 100%;
491
+ padding: 7px 10px;
492
+ border: none;
493
+ border-radius: 4px;
494
+ background: transparent;
495
+ color: var(--brevo-text);
496
+ font-family: var(--brevo-font);
497
+ font-size: 13px;
498
+ cursor: pointer;
499
+ text-align: left;
500
+ gap: 8px;
501
+ }
502
+
503
+ .brevo-dropdown__menu button:hover {
504
+ background: var(--brevo-btn-hover);
505
+ }
506
+
507
+ .brevo-var-label {
508
+ flex: 1;
509
+ }
510
+
511
+ .brevo-var-key {
512
+ font-size: 11px;
513
+ color: var(--brevo-accent);
514
+ background: rgba(99, 102, 241, 0.1);
515
+ padding: 2px 6px;
516
+ border-radius: 3px;
517
+ }
518
+
519
+ /* ── Editor ── */
520
+ .brevo-editor-wrapper {
521
+ background: var(--brevo-bg);
522
+ }
523
+
524
+ .brevo-editor {
525
+ min-height: 320px;
526
+ max-height: 600px;
527
+ overflow-y: auto;
528
+ padding: 20px 24px;
529
+ color: var(--brevo-text);
530
+ font-size: 15px;
531
+ line-height: 1.7;
532
+ outline: none;
533
+ word-wrap: break-word;
534
+ overflow-wrap: break-word;
535
+ }
536
+
537
+ .brevo-editor:empty::before {
538
+ content: attr(data-placeholder);
539
+ color: var(--brevo-text-muted);
540
+ opacity: 0.5;
541
+ pointer-events: none;
542
+ }
543
+
544
+ .brevo-editor :global(h1) { font-size: 1.8em; font-weight: 700; margin: 0.4em 0; }
545
+ .brevo-editor :global(h2) { font-size: 1.4em; font-weight: 600; margin: 0.4em 0; }
546
+ .brevo-editor :global(h3) { font-size: 1.15em; font-weight: 600; margin: 0.4em 0; }
547
+ .brevo-editor :global(a) { color: var(--brevo-accent-hover); text-decoration: underline; }
548
+ .brevo-editor :global(ul),
549
+ .brevo-editor :global(ol) { padding-left: 1.6em; }
550
+
551
+ /* ── Source ── */
552
+ .brevo-source {
553
+ width: 100%;
554
+ min-height: 320px;
555
+ max-height: 600px;
556
+ padding: 16px 20px;
557
+ border: none;
558
+ background: #0d1117;
559
+ color: #c9d1d9;
560
+ font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
561
+ font-size: 13px;
562
+ line-height: 1.65;
563
+ resize: vertical;
564
+ outline: none;
565
+ tab-size: 2;
566
+ box-sizing: border-box;
567
+ }
568
+
569
+ .brevo-source::placeholder {
570
+ color: #484f58;
571
+ }
572
+ </style>