@visns-studio/visns-components 6.34.0 → 6.35.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,486 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import {
3
+ AlignCenter,
4
+ AlignLeft,
5
+ AlignRight,
6
+ ArrowDown,
7
+ ArrowUp,
8
+ Bold,
9
+ ImageUp,
10
+ Italic,
11
+ Link2,
12
+ List,
13
+ Trash2,
14
+ } from 'lucide-react';
15
+
16
+ import styles from '../styles/EmailCampaigns.module.scss';
17
+ import { api, BLOCK_TYPES, newBlock } from './emailCampaignApi';
18
+
19
+ /**
20
+ * The campaign's content, as an ordered list of blocks.
21
+ *
22
+ * Each block is a card with its own fields and the same three controls: move
23
+ * up, move down, remove. The server turns the blocks into the email
24
+ * (EmailCampaignRenderer) and cleans the rich text again on the way, so nothing
25
+ * here is trusted for safety; it is only an editor.
26
+ *
27
+ * Merge tags are typed as `{first_name}` and friends. The "Insert" menu puts one
28
+ * at the cursor so nobody has to remember the spelling.
29
+ */
30
+ const TAG_LABELS = {
31
+ first_name: 'First name',
32
+ last_name: 'Last name',
33
+ name: 'Full name',
34
+ company: 'Company',
35
+ email: 'Email',
36
+ };
37
+
38
+ const BlockEditor = ({ blocks, onChange, routes, disabled, tags = Object.keys(TAG_LABELS) }) => {
39
+ const update = (index, patch) =>
40
+ onChange(blocks.map((block, at) => (at === index ? { ...block, ...patch } : block)));
41
+
42
+ const move = (index, by) => {
43
+ const target = index + by;
44
+
45
+ if (target < 0 || target >= blocks.length) return;
46
+
47
+ const next = [...blocks];
48
+ [next[index], next[target]] = [next[target], next[index]];
49
+ onChange(next);
50
+ };
51
+
52
+ const remove = (index) => onChange(blocks.filter((block, at) => at !== index));
53
+
54
+ return (
55
+ <div className={styles.blocks}>
56
+ {blocks.length === 0 && (
57
+ <p className={styles.quiet}>No content yet. Add a heading or some text below.</p>
58
+ )}
59
+
60
+ {blocks.map((block, index) => (
61
+ <div className={styles.block} key={index}>
62
+ <div className={styles.blockHead}>
63
+ <span className={styles.blockType}>
64
+ {BLOCK_TYPES.find((entry) => entry.type === block.type)?.label || block.type}
65
+ </span>
66
+ <span className={styles.blockTools}>
67
+ <IconButton
68
+ title="Move this block up"
69
+ disabled={disabled || index === 0}
70
+ onClick={() => move(index, -1)}
71
+ >
72
+ <ArrowUp size={14} aria-hidden="true" />
73
+ </IconButton>
74
+ <IconButton
75
+ title="Move this block down"
76
+ disabled={disabled || index === blocks.length - 1}
77
+ onClick={() => move(index, 1)}
78
+ >
79
+ <ArrowDown size={14} aria-hidden="true" />
80
+ </IconButton>
81
+ <IconButton
82
+ title="Remove this block"
83
+ danger
84
+ disabled={disabled}
85
+ onClick={() => remove(index)}
86
+ >
87
+ <Trash2 size={14} aria-hidden="true" />
88
+ </IconButton>
89
+ </span>
90
+ </div>
91
+
92
+ <BlockFields
93
+ block={block}
94
+ disabled={disabled}
95
+ tags={tags}
96
+ routes={routes}
97
+ onChange={(patch) => update(index, patch)}
98
+ />
99
+ </div>
100
+ ))}
101
+
102
+ {!disabled && (
103
+ <div className={styles.addRow}>
104
+ <span className={styles.addLabel}>Add</span>
105
+ {BLOCK_TYPES.map((entry) => (
106
+ <button
107
+ key={entry.type}
108
+ type="button"
109
+ className={styles.addButton}
110
+ title={`Add a ${entry.label.toLowerCase()} block at the end`}
111
+ onClick={() => onChange([...blocks, newBlock(entry.type)])}
112
+ >
113
+ {entry.label}
114
+ </button>
115
+ ))}
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ };
121
+
122
+ const BlockFields = ({ block, onChange, disabled, tags, routes }) => {
123
+ switch (block.type) {
124
+ case 'heading':
125
+ return (
126
+ <div className={styles.fieldRow}>
127
+ <TextWithTags
128
+ value={block.text || ''}
129
+ placeholder="Heading"
130
+ disabled={disabled}
131
+ tags={tags}
132
+ onChange={(text) => onChange({ text })}
133
+ />
134
+ <select
135
+ className={styles.select}
136
+ value={block.level === 2 ? 2 : 1}
137
+ disabled={disabled}
138
+ title="Heading size"
139
+ onChange={(event) => onChange({ level: Number(event.target.value) })}
140
+ >
141
+ <option value={1}>Large</option>
142
+ <option value={2}>Small</option>
143
+ </select>
144
+ <AlignPicker value={block.align} disabled={disabled} onChange={(align) => onChange({ align })} />
145
+ </div>
146
+ );
147
+
148
+ case 'text':
149
+ return (
150
+ <RichText
151
+ value={block.html || ''}
152
+ disabled={disabled}
153
+ tags={tags}
154
+ onChange={(html) => onChange({ html })}
155
+ />
156
+ );
157
+
158
+ case 'columns':
159
+ return (
160
+ <div className={styles.columnsEdit}>
161
+ <RichText value={block.left || ''} disabled={disabled} tags={tags} onChange={(left) => onChange({ left })} />
162
+ <RichText value={block.right || ''} disabled={disabled} tags={tags} onChange={(right) => onChange({ right })} />
163
+ </div>
164
+ );
165
+
166
+ case 'button':
167
+ return (
168
+ <div className={styles.fieldRow}>
169
+ <TextWithTags
170
+ value={block.label || ''}
171
+ placeholder="Button label"
172
+ disabled={disabled}
173
+ tags={tags}
174
+ onChange={(label) => onChange({ label })}
175
+ />
176
+ <input
177
+ type="url"
178
+ className={styles.input}
179
+ value={block.url || ''}
180
+ placeholder="https://"
181
+ disabled={disabled}
182
+ title="Where the button goes"
183
+ onChange={(event) => onChange({ url: event.target.value })}
184
+ />
185
+ <AlignPicker value={block.align} disabled={disabled} onChange={(align) => onChange({ align })} />
186
+ </div>
187
+ );
188
+
189
+ case 'image':
190
+ return <ImageFields block={block} disabled={disabled} routes={routes} onChange={onChange} />;
191
+
192
+ case 'spacer':
193
+ return (
194
+ <label className={styles.inline}>
195
+ Height
196
+ <input
197
+ type="range"
198
+ min={8}
199
+ max={80}
200
+ step={4}
201
+ value={block.height || 24}
202
+ disabled={disabled}
203
+ onChange={(event) => onChange({ height: Number(event.target.value) })}
204
+ />
205
+ <span className={styles.quiet}>{block.height || 24}px</span>
206
+ </label>
207
+ );
208
+
209
+ case 'divider':
210
+ return <p className={styles.quiet}>A thin line across the email.</p>;
211
+
212
+ default:
213
+ return null;
214
+ }
215
+ };
216
+
217
+ const ImageFields = ({ block, onChange, disabled, routes }) => {
218
+ const [uploading, setUploading] = useState(false);
219
+ const [error, setError] = useState('');
220
+ const file = useRef(null);
221
+
222
+ const upload = async (picked) => {
223
+ if (!picked) return;
224
+
225
+ const body = new FormData();
226
+ body.append('image', picked);
227
+ setUploading(true);
228
+ setError('');
229
+
230
+ const result = await api(routes.images, 'POST', body);
231
+
232
+ setUploading(false);
233
+
234
+ if (result.ok && result.data?.url) {
235
+ onChange({ url: result.data.url, alt: block.alt || picked.name.replace(/\.[a-z0-9]+$/i, '') });
236
+ } else {
237
+ setError(result.message || 'The image could not be uploaded.');
238
+ }
239
+ };
240
+
241
+ return (
242
+ <div className={styles.imageEdit}>
243
+ {block.url ? (
244
+ <img src={block.url} alt="" className={styles.imageThumb} />
245
+ ) : (
246
+ <div className={styles.imageEmpty}>No image yet</div>
247
+ )}
248
+
249
+ <div className={styles.imageFields}>
250
+ <div className={styles.fieldRow}>
251
+ <button
252
+ type="button"
253
+ className={styles.button}
254
+ disabled={disabled || uploading}
255
+ title="Upload a JPEG, PNG, GIF or WebP from this computer"
256
+ onClick={() => file.current?.click()}
257
+ >
258
+ <ImageUp size={14} aria-hidden="true" />
259
+ {uploading ? 'Uploading…' : 'Upload image'}
260
+ </button>
261
+ <input
262
+ ref={file}
263
+ type="file"
264
+ accept="image/jpeg,image/png,image/gif,image/webp"
265
+ hidden
266
+ onChange={(event) => upload(event.target.files?.[0])}
267
+ />
268
+ <input
269
+ type="url"
270
+ className={styles.input}
271
+ value={block.url || ''}
272
+ placeholder="or paste an image address (https://…)"
273
+ disabled={disabled}
274
+ onChange={(event) => onChange({ url: event.target.value })}
275
+ />
276
+ </div>
277
+ <div className={styles.fieldRow}>
278
+ <input
279
+ type="text"
280
+ className={styles.input}
281
+ value={block.alt || ''}
282
+ placeholder="Describe the image (shown if it does not load)"
283
+ disabled={disabled}
284
+ onChange={(event) => onChange({ alt: event.target.value })}
285
+ />
286
+ <input
287
+ type="url"
288
+ className={styles.input}
289
+ value={block.href || ''}
290
+ placeholder="Link when clicked (optional)"
291
+ disabled={disabled}
292
+ onChange={(event) => onChange({ href: event.target.value })}
293
+ />
294
+ </div>
295
+ <label className={styles.inline}>
296
+ Width
297
+ <input
298
+ type="range"
299
+ min={20}
300
+ max={100}
301
+ step={5}
302
+ value={block.width || 100}
303
+ disabled={disabled}
304
+ onChange={(event) => onChange({ width: Number(event.target.value) })}
305
+ />
306
+ <span className={styles.quiet}>{block.width || 100}%</span>
307
+ <AlignPicker value={block.align || 'center'} disabled={disabled} onChange={(align) => onChange({ align })} />
308
+ </label>
309
+ {error && <p className={styles.fieldError}>{error}</p>}
310
+ </div>
311
+ </div>
312
+ );
313
+ };
314
+
315
+ /** A single-line field with a merge-tag menu beside it. */
316
+ const TextWithTags = ({ value, onChange, placeholder, disabled, tags }) => {
317
+ const input = useRef(null);
318
+
319
+ const insert = (tag) => {
320
+ const element = input.current;
321
+ const token = `{${tag}}`;
322
+
323
+ if (!element) {
324
+ onChange(`${value}${token}`);
325
+
326
+ return;
327
+ }
328
+
329
+ const start = element.selectionStart ?? value.length;
330
+ const end = element.selectionEnd ?? value.length;
331
+ onChange(value.slice(0, start) + token + value.slice(end));
332
+ };
333
+
334
+ return (
335
+ <span className={styles.withTags}>
336
+ <input
337
+ ref={input}
338
+ type="text"
339
+ className={styles.input}
340
+ value={value}
341
+ placeholder={placeholder}
342
+ disabled={disabled}
343
+ onChange={(event) => onChange(event.target.value)}
344
+ />
345
+ <TagMenu tags={tags} disabled={disabled} onPick={insert} />
346
+ </span>
347
+ );
348
+ };
349
+
350
+ const TagMenu = ({ tags, onPick, disabled }) => (
351
+ <select
352
+ className={styles.tagSelect}
353
+ value=""
354
+ disabled={disabled}
355
+ title="Insert a personal detail, filled in for each recipient"
356
+ aria-label="Insert a personal detail"
357
+ onChange={(event) => {
358
+ if (event.target.value) onPick(event.target.value);
359
+ }}
360
+ >
361
+ <option value="">Insert…</option>
362
+ {tags.map((tag) => (
363
+ <option key={tag} value={tag}>
364
+ {TAG_LABELS[tag] || tag}
365
+ </option>
366
+ ))}
367
+ </select>
368
+ );
369
+
370
+ /**
371
+ * A small rich-text box: bold, italic, link, bullet list, and a merge tag.
372
+ *
373
+ * `contentEditable` with `execCommand`: deprecated on paper and supported by
374
+ * every browser in practice, and a whole editor library for five buttons would
375
+ * be a bundle every reader of every page pays for. The HTML it produces is
376
+ * cleaned on the server before it goes anywhere.
377
+ *
378
+ * The box is written to only when the value changes from OUTSIDE (a template
379
+ * loaded, a reset) — writing on every keystroke would throw the cursor to the
380
+ * start of the box.
381
+ */
382
+ const RichText = ({ value, onChange, disabled, tags }) => {
383
+ const box = useRef(null);
384
+ const last = useRef(value);
385
+
386
+ useEffect(() => {
387
+ if (box.current && value !== last.current) {
388
+ box.current.innerHTML = value;
389
+ last.current = value;
390
+ }
391
+ }, [value]);
392
+
393
+ useEffect(() => {
394
+ if (box.current) box.current.innerHTML = value;
395
+ // First paint only; later outside changes go through the effect above.
396
+ // eslint-disable-next-line react-hooks/exhaustive-deps
397
+ }, []);
398
+
399
+ const emit = () => {
400
+ const html = box.current?.innerHTML ?? '';
401
+ last.current = html;
402
+ onChange(html);
403
+ };
404
+
405
+ const run = (command, argument = null) => {
406
+ box.current?.focus();
407
+ document.execCommand(command, false, argument);
408
+ emit();
409
+ };
410
+
411
+ const link = () => {
412
+ const url = window.prompt('Link address (https://…)');
413
+
414
+ if (url && /^(https?:\/\/|mailto:)/i.test(url.trim())) {
415
+ run('createLink', url.trim());
416
+ }
417
+ };
418
+
419
+ return (
420
+ <div className={styles.rich}>
421
+ <div className={styles.richBar}>
422
+ <IconButton title="Bold" disabled={disabled} onClick={() => run('bold')}>
423
+ <Bold size={14} aria-hidden="true" />
424
+ </IconButton>
425
+ <IconButton title="Italic" disabled={disabled} onClick={() => run('italic')}>
426
+ <Italic size={14} aria-hidden="true" />
427
+ </IconButton>
428
+ <IconButton title="Link the selected words" disabled={disabled} onClick={link}>
429
+ <Link2 size={14} aria-hidden="true" />
430
+ </IconButton>
431
+ <IconButton title="Bulleted list" disabled={disabled} onClick={() => run('insertUnorderedList')}>
432
+ <List size={14} aria-hidden="true" />
433
+ </IconButton>
434
+ <TagMenu tags={tags} disabled={disabled} onPick={(tag) => run('insertText', `{${tag}}`)} />
435
+ </div>
436
+ <div
437
+ ref={box}
438
+ className={styles.richBox}
439
+ contentEditable={!disabled}
440
+ suppressContentEditableWarning
441
+ role="textbox"
442
+ aria-multiline="true"
443
+ onInput={emit}
444
+ onBlur={emit}
445
+ />
446
+ </div>
447
+ );
448
+ };
449
+
450
+ const AlignPicker = ({ value = 'left', onChange, disabled }) => (
451
+ <span className={styles.alignPicker} role="group" aria-label="Alignment">
452
+ {[
453
+ ['left', AlignLeft],
454
+ ['center', AlignCenter],
455
+ ['right', AlignRight],
456
+ ].map(([key, Icon]) => (
457
+ <IconButton
458
+ key={key}
459
+ title={`Align ${key}`}
460
+ pressed={value === key}
461
+ disabled={disabled}
462
+ onClick={() => onChange(key)}
463
+ >
464
+ <Icon size={14} aria-hidden="true" />
465
+ </IconButton>
466
+ ))}
467
+ </span>
468
+ );
469
+
470
+ const IconButton = ({ title, onClick, disabled, danger, pressed, children }) => (
471
+ <button
472
+ type="button"
473
+ className={[styles.iconButton, danger ? styles.iconDanger : '', pressed ? styles.iconOn : '']
474
+ .filter(Boolean)
475
+ .join(' ')}
476
+ title={title}
477
+ aria-label={title}
478
+ aria-pressed={pressed === undefined ? undefined : pressed}
479
+ disabled={disabled}
480
+ onClick={onClick}
481
+ >
482
+ {children}
483
+ </button>
484
+ );
485
+
486
+ export default BlockEditor;