@hed-hog/core 0.0.185 → 0.0.190

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.
Files changed (55) hide show
  1. package/hedhog/frontend/app/account/2fa/page.tsx.ejs +5 -0
  2. package/hedhog/frontend/app/account/accounts/page.tsx.ejs +5 -0
  3. package/hedhog/frontend/app/account/components/active-sessions.tsx.ejs +356 -0
  4. package/hedhog/frontend/app/account/components/change-email-form.tsx.ejs +379 -0
  5. package/hedhog/frontend/app/account/components/change-password-form.tsx.ejs +184 -0
  6. package/hedhog/frontend/app/account/components/connected-accounts.tsx.ejs +144 -0
  7. package/hedhog/frontend/app/account/components/email-request-dialog.tsx.ejs +96 -0
  8. package/hedhog/frontend/app/account/components/mfa-add-buttons.tsx.ejs +43 -0
  9. package/hedhog/frontend/app/account/components/mfa-method-card.tsx.ejs +115 -0
  10. package/hedhog/frontend/app/account/components/mfa-setup-dialog.tsx.ejs +236 -0
  11. package/hedhog/frontend/app/account/components/profile-form.tsx.ejs +209 -0
  12. package/hedhog/frontend/app/account/components/recovery-codes-dialog.tsx.ejs +192 -0
  13. package/hedhog/frontend/app/account/components/regenerate-codes-dialog.tsx.ejs +372 -0
  14. package/hedhog/frontend/app/account/components/remove-mfa-dialog.tsx.ejs +337 -0
  15. package/hedhog/frontend/app/account/components/two-factor-auth.tsx.ejs +393 -0
  16. package/hedhog/frontend/app/account/components/verify-before-add-dialog.tsx.ejs +332 -0
  17. package/hedhog/frontend/app/account/email/page.tsx.ejs +5 -0
  18. package/hedhog/frontend/app/account/hooks/use-mfa-methods.ts.ejs +27 -0
  19. package/hedhog/frontend/app/account/hooks/use-mfa-setup.ts.ejs +461 -0
  20. package/hedhog/frontend/app/account/layout.tsx.ejs +105 -0
  21. package/hedhog/frontend/app/account/lib/mfa-utils.tsx.ejs +37 -0
  22. package/hedhog/frontend/app/account/page.tsx.ejs +5 -0
  23. package/hedhog/frontend/app/account/password/page.tsx.ejs +5 -0
  24. package/hedhog/frontend/app/account/profile/page.tsx.ejs +5 -0
  25. package/hedhog/frontend/app/account/sessions/page.tsx.ejs +5 -0
  26. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +490 -0
  27. package/hedhog/frontend/app/configurations/[slug]/page.tsx.ejs +62 -0
  28. package/hedhog/frontend/app/configurations/layout.tsx.ejs +316 -0
  29. package/hedhog/frontend/app/configurations/page.tsx.ejs +35 -0
  30. package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +351 -0
  31. package/hedhog/frontend/app/dashboard/[slug]/page.tsx.ejs +11 -0
  32. package/hedhog/frontend/app/dashboard/[slug]/types.ts.ejs +62 -0
  33. package/hedhog/frontend/app/dashboard/[slug]/widget-renderer.tsx.ejs +45 -0
  34. package/hedhog/frontend/app/dashboard/dashboard.css.ejs +196 -0
  35. package/hedhog/frontend/app/dashboard/management/page.tsx.ejs +63 -0
  36. package/hedhog/frontend/app/dashboard/management/tabs/component-roles-tab.tsx.ejs +516 -0
  37. package/hedhog/frontend/app/dashboard/management/tabs/components-tab.tsx.ejs +753 -0
  38. package/hedhog/frontend/app/dashboard/management/tabs/dashboard-roles-tab.tsx.ejs +516 -0
  39. package/hedhog/frontend/app/dashboard/management/tabs/dashboards-tab.tsx.ejs +489 -0
  40. package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +621 -0
  41. package/hedhog/frontend/app/dashboard/page.tsx.ejs +14 -0
  42. package/hedhog/frontend/app/mail/log/page.tsx.ejs +312 -0
  43. package/hedhog/frontend/app/mail/template/page.tsx.ejs +1177 -0
  44. package/hedhog/frontend/app/preferences/page.tsx.ejs +448 -0
  45. package/hedhog/frontend/app/roles/menus.tsx.ejs +504 -0
  46. package/hedhog/frontend/app/roles/page.tsx.ejs +814 -0
  47. package/hedhog/frontend/app/roles/routes.tsx.ejs +397 -0
  48. package/hedhog/frontend/app/roles/users.tsx.ejs +306 -0
  49. package/hedhog/frontend/app/users/active-session.tsx.ejs +159 -0
  50. package/hedhog/frontend/app/users/identifiers.tsx.ejs +279 -0
  51. package/hedhog/frontend/app/users/page.tsx.ejs +1257 -0
  52. package/hedhog/frontend/app/users/permissions.tsx.ejs +155 -0
  53. package/hedhog/frontend/messages/en.json +1080 -0
  54. package/hedhog/frontend/messages/pt.json +1135 -0
  55. package/package.json +4 -4
@@ -0,0 +1,1177 @@
1
+ 'use client';
2
+
3
+ import type React from 'react';
4
+
5
+ import { RichTextEditor } from '@/components/rich-text-editor';
6
+ import {
7
+ AlertDialog,
8
+ AlertDialogAction,
9
+ AlertDialogCancel,
10
+ AlertDialogContent,
11
+ AlertDialogDescription,
12
+ AlertDialogFooter,
13
+ AlertDialogHeader,
14
+ AlertDialogTitle,
15
+ } from '@/components/ui/alert-dialog';
16
+ import { Badge } from '@/components/ui/badge';
17
+ import { Button } from '@/components/ui/button';
18
+ import { Checkbox } from '@/components/ui/checkbox';
19
+ import {
20
+ Dialog,
21
+ DialogContent,
22
+ DialogDescription,
23
+ DialogHeader,
24
+ DialogTitle,
25
+ } from '@/components/ui/dialog';
26
+ import { Input } from '@/components/ui/input';
27
+ import { Label } from '@/components/ui/label';
28
+ import {
29
+ Select,
30
+ SelectContent,
31
+ SelectItem,
32
+ SelectTrigger,
33
+ SelectValue,
34
+ } from '@/components/ui/select';
35
+ import {
36
+ Table,
37
+ TableBody,
38
+ TableCell,
39
+ TableHead,
40
+ TableHeader,
41
+ TableRow,
42
+ } from '@/components/ui/table';
43
+ import { TagsInput } from '@/components/ui/tags-input';
44
+ import { usePagination } from '@/hooks/use-pagination';
45
+ import { formatDate } from '@/lib/format-date';
46
+ import { useApp } from '@hed-hog/next-app-provider';
47
+ import {
48
+ Database,
49
+ Download,
50
+ FileDown,
51
+ Mail as MailIcon,
52
+ Pencil,
53
+ Trash2,
54
+ } from 'lucide-react';
55
+ import { useTranslations } from 'next-intl';
56
+ import { useEffect, useState } from 'react';
57
+ import { toast } from 'sonner';
58
+ import { PageHeader } from '../../../../components/entity-list/page-header';
59
+
60
+ interface Mail {
61
+ id: number;
62
+ slug: string;
63
+ created_at: string;
64
+ updated_at: string;
65
+ locale_id: number;
66
+ mail_id: number;
67
+ subject: string;
68
+ body: string;
69
+ mail_var: any[];
70
+ locale: {
71
+ code: string;
72
+ };
73
+ }
74
+
75
+ interface ImportResponse {
76
+ conflicts?: string[];
77
+ success?: boolean;
78
+ imported?: number;
79
+ templates?: number[];
80
+ }
81
+
82
+ export default function EmailTemplatesPage() {
83
+ const { locales, request, currentLocaleCode, getSettingValue } = useApp();
84
+ const t = useTranslations('core.MailPage');
85
+ const { items, refetch } = usePagination({ url: '/mail' });
86
+ const [templates, setTemplates] = useState<Mail[]>([]);
87
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
88
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
89
+ const [templateToDelete, setTemplateToDelete] = useState<number | null>(null);
90
+ const [selectedTemplate, setSelectedTemplate] = useState<Mail | null>(null);
91
+ const [selectedLocaleCode, setSelectedLocaleCode] = useState<string>('en');
92
+ const [editMode, setEditMode] = useState(false);
93
+ const [formData, setFormData] = useState({
94
+ slug: '',
95
+ subject: '',
96
+ body: '',
97
+ variables: [] as string[],
98
+ });
99
+ const [variableTestValues, setVariableTestValues] = useState<
100
+ Record<string, string>
101
+ >({});
102
+ const [isTestEmailDialogOpen, setIsTestEmailDialogOpen] = useState(false);
103
+ const [testEmailRecipient, setTestEmailRecipient] = useState('');
104
+ const [testEmailVariableValues, setTestEmailVariableValues] = useState<
105
+ Record<string, string>
106
+ >({});
107
+ const [selectedTemplates, setSelectedTemplates] = useState<Set<number>>(
108
+ new Set()
109
+ );
110
+ const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
111
+ const [importFile, setImportFile] = useState<File | null>(null);
112
+ const [conflictingSlugs, setConflictingSlugs] = useState<string[]>([]);
113
+ const [isConflictDialogOpen, setIsConflictDialogOpen] = useState(false);
114
+
115
+ useEffect(() => {
116
+ if (items) {
117
+ const seen = new Set<number>();
118
+ const uniqueTemplates = items.filter((template: Mail) => {
119
+ if (seen.has(template.mail_id)) {
120
+ return false;
121
+ }
122
+ seen.add(template.mail_id);
123
+ return true;
124
+ });
125
+ setTemplates(uniqueTemplates);
126
+ }
127
+ }, [items]);
128
+
129
+ useEffect(() => {
130
+ const savedValues = localStorage.getItem('mail-template-variable-values');
131
+ if (savedValues) {
132
+ try {
133
+ const parsed = JSON.parse(savedValues);
134
+ setVariableTestValues(parsed);
135
+ setTestEmailVariableValues(parsed);
136
+ } catch (error) {
137
+ console.error('Error loading saved variable values:', error);
138
+ }
139
+ }
140
+
141
+ const savedRecipient = localStorage.getItem('mail-template-test-recipient');
142
+ if (savedRecipient) {
143
+ setTestEmailRecipient(savedRecipient);
144
+ }
145
+ }, []);
146
+
147
+ useEffect(() => {
148
+ if (Object.keys(variableTestValues).length > 0) {
149
+ localStorage.setItem(
150
+ 'mail-template-variable-values',
151
+ JSON.stringify(variableTestValues)
152
+ );
153
+ }
154
+ }, [variableTestValues]);
155
+
156
+ useEffect(() => {
157
+ if (Object.keys(testEmailVariableValues).length > 0) {
158
+ localStorage.setItem(
159
+ 'mail-template-variable-values',
160
+ JSON.stringify(testEmailVariableValues)
161
+ );
162
+ }
163
+ }, [testEmailVariableValues]);
164
+
165
+ useEffect(() => {
166
+ if (testEmailRecipient) {
167
+ localStorage.setItem('mail-template-test-recipient', testEmailRecipient);
168
+ }
169
+ }, [testEmailRecipient]);
170
+
171
+ const handleCreate = () => {
172
+ setEditMode(false);
173
+ setSelectedTemplate(null);
174
+ setSelectedLocaleCode(locales[0]?.code || 'en');
175
+ setFormData({ slug: '', subject: '', body: '', variables: [] });
176
+
177
+ const savedValues = localStorage.getItem('mail-template-variable-values');
178
+ if (savedValues) {
179
+ try {
180
+ setVariableTestValues(JSON.parse(savedValues));
181
+ } catch (error) {
182
+ setVariableTestValues({});
183
+ }
184
+ } else {
185
+ setVariableTestValues({});
186
+ }
187
+
188
+ setIsDialogOpen(true);
189
+ };
190
+
191
+ const getLocaleIdFromCode = (code: string): number => {
192
+ const index = locales.findIndex((l) => l.code === code);
193
+ return index >= 0 ? index + 1 : 1;
194
+ };
195
+
196
+ const handleEdit = async (template: Mail) => {
197
+ setEditMode(true);
198
+ setSelectedTemplate(template);
199
+
200
+ const localeCode = template.locale?.code || locales[0]?.code || 'en';
201
+ setSelectedLocaleCode(localeCode);
202
+
203
+ setFormData({
204
+ slug: template.slug,
205
+ subject: template.subject || '',
206
+ body: template.body || '',
207
+ variables: template.mail_var.map((mv) => mv.name) || [],
208
+ });
209
+
210
+ const savedValues = localStorage.getItem('mail-template-variable-values');
211
+ if (savedValues) {
212
+ try {
213
+ setVariableTestValues(JSON.parse(savedValues));
214
+ } catch (error) {
215
+ setVariableTestValues({});
216
+ }
217
+ } else {
218
+ setVariableTestValues({});
219
+ }
220
+
221
+ setIsDialogOpen(true);
222
+
223
+ try {
224
+ const { data } = await request<Mail>({
225
+ url: `/mail/${template.mail_id || template.id}?locale=${localeCode}`,
226
+ method: 'GET',
227
+ });
228
+
229
+ setFormData({
230
+ slug: data.slug,
231
+ subject: data.subject || '',
232
+ body: data.body || '',
233
+ variables: data.mail_var.map((mv) => mv.name) || [],
234
+ });
235
+ } catch (error) {
236
+ console.error('Error loading template:', error);
237
+ }
238
+ };
239
+
240
+ const handleLocaleChange = async (localeCode: string) => {
241
+ setSelectedLocaleCode(localeCode);
242
+
243
+ if (selectedTemplate) {
244
+ try {
245
+ const { data } = await request<Mail>({
246
+ url: `/mail/${selectedTemplate.mail_id || selectedTemplate.id}?locale=${localeCode}`,
247
+ method: 'GET',
248
+ });
249
+
250
+ setFormData({
251
+ ...formData,
252
+ subject: data.subject || '',
253
+ body: data.body || '',
254
+ });
255
+ } catch (error) {
256
+ console.error('Error loading template locale:', error);
257
+ }
258
+ }
259
+ };
260
+
261
+ const handleDelete = (id: number) => {
262
+ setTemplateToDelete(id);
263
+ setIsDeleteDialogOpen(true);
264
+ };
265
+
266
+ const confirmDelete = async () => {
267
+ if (templateToDelete !== null) {
268
+ try {
269
+ await request({
270
+ url: '/mail',
271
+ method: 'DELETE',
272
+ data: { ids: [templateToDelete] },
273
+ });
274
+ refetch();
275
+ toast.success(t('deleteSuccess'));
276
+ setTemplateToDelete(null);
277
+ setIsDeleteDialogOpen(false);
278
+ } catch (error) {
279
+ toast.error(t('deleteError'));
280
+ console.error(error);
281
+ }
282
+ }
283
+ };
284
+
285
+ const handleGenerateMigration = async () => {
286
+ if (!selectedTemplate) {
287
+ toast.error(t('noTemplateSelected'));
288
+ return;
289
+ }
290
+
291
+ try {
292
+ const translationsData = await Promise.all(
293
+ locales.map(async (locale) => {
294
+ try {
295
+ const { data } = await request<Mail>({
296
+ url: `/mail/${selectedTemplate.mail_id || selectedTemplate.id}?locale=${locale.code}`,
297
+ method: 'GET',
298
+ });
299
+ return {
300
+ locale_code: locale.code,
301
+ subject: data.subject,
302
+ body: data.body,
303
+ };
304
+ } catch (error) {
305
+ console.warn(
306
+ `Failed to fetch translation for ${locale.code}:`,
307
+ error
308
+ );
309
+ return null;
310
+ }
311
+ })
312
+ );
313
+
314
+ const validTranslations = translationsData.filter(
315
+ (t) => t !== null && t.subject && t.body
316
+ );
317
+
318
+ if (validTranslations.length === 0) {
319
+ toast.error(t('noValidTranslations'));
320
+ return;
321
+ }
322
+
323
+ await request({
324
+ url: '/install/generate-mail-migration',
325
+ method: 'POST',
326
+ data: {
327
+ slug: formData.slug,
328
+ translations: validTranslations,
329
+ variables: formData.variables,
330
+ },
331
+ });
332
+
333
+ toast.success(t('migrationSuccess'));
334
+ } catch (error) {
335
+ console.error('Error generating migration:', error);
336
+ toast.error(t('migrationError'));
337
+ }
338
+ };
339
+
340
+ const handleSendTestEmail = () => {
341
+ const savedRecipient = localStorage.getItem('mail-template-test-recipient');
342
+ if (savedRecipient) {
343
+ setTestEmailRecipient(savedRecipient);
344
+ } else {
345
+ setTestEmailRecipient('');
346
+ }
347
+
348
+ const savedValues = localStorage.getItem('mail-template-variable-values');
349
+ if (savedValues) {
350
+ try {
351
+ setTestEmailVariableValues(JSON.parse(savedValues));
352
+ } catch (error) {
353
+ setTestEmailVariableValues({});
354
+ }
355
+ } else {
356
+ setTestEmailVariableValues({});
357
+ }
358
+
359
+ setIsTestEmailDialogOpen(true);
360
+ };
361
+
362
+ const handleConfirmSendTestEmail = async () => {
363
+ try {
364
+ await request({
365
+ url: '/mail/test',
366
+ method: 'POST',
367
+ data: {
368
+ slug: formData.slug,
369
+ email: testEmailRecipient,
370
+ subject: formData.subject,
371
+ body: formData.body,
372
+ variables: testEmailVariableValues,
373
+ },
374
+ });
375
+ toast.success(t('testEmailSuccess'));
376
+ setIsTestEmailDialogOpen(false);
377
+ } catch (error) {
378
+ console.error('Error sending test email:', error);
379
+ toast.error(t('testEmailError'));
380
+ }
381
+ };
382
+
383
+ const handleSubmit = async (e: React.FormEvent) => {
384
+ e.preventDefault();
385
+ const localeId = getLocaleIdFromCode(selectedLocaleCode);
386
+ const payload = {
387
+ slug: formData.slug,
388
+ mail_locale: [
389
+ {
390
+ locale_id: localeId,
391
+ subject: formData.subject,
392
+ body: formData.body,
393
+ },
394
+ ],
395
+ mail_var: formData.variables.map((v) => ({
396
+ name: v.trim(),
397
+ })),
398
+ };
399
+
400
+ try {
401
+ if (editMode && selectedTemplate) {
402
+ await request<Mail>({
403
+ url: `/mail/${selectedTemplate.mail_id || selectedTemplate.id}`,
404
+ method: 'PATCH',
405
+ data: payload,
406
+ });
407
+ toast.success(t('templateEditSuccess'));
408
+ } else {
409
+ await request<Mail>({
410
+ url: '/mail',
411
+ method: 'POST',
412
+ data: payload,
413
+ });
414
+ toast.success(t('templateCreateSuccess'));
415
+ }
416
+
417
+ refetch();
418
+ setIsDialogOpen(false);
419
+ } catch (error) {
420
+ console.error('Error saving template:', error);
421
+ }
422
+ };
423
+
424
+ const handleToggleTemplate = (id: number) => {
425
+ const newSelected = new Set(selectedTemplates);
426
+ if (newSelected.has(id)) {
427
+ newSelected.delete(id);
428
+ } else {
429
+ newSelected.add(id);
430
+ }
431
+ setSelectedTemplates(newSelected);
432
+ };
433
+
434
+ const handleToggleAll = () => {
435
+ if (selectedTemplates.size === templates.length) {
436
+ setSelectedTemplates(new Set());
437
+ } else {
438
+ setSelectedTemplates(new Set(templates.map((t) => t.mail_id || t.id)));
439
+ }
440
+ };
441
+
442
+ const handleExportSelected = async () => {
443
+ if (selectedTemplates.size === 0) {
444
+ toast.error(t('noTemplatesSelected'));
445
+ return;
446
+ }
447
+
448
+ try {
449
+ const templatesToExport = templates.filter((t) =>
450
+ selectedTemplates.has(t.mail_id || t.id)
451
+ );
452
+
453
+ const exportData = await Promise.all(
454
+ templatesToExport.map(async (template) => {
455
+ const allTranslations = await Promise.all(
456
+ locales.map(async (locale) => {
457
+ try {
458
+ const { data } = await request<Mail>({
459
+ url: `/mail/${template.mail_id || template.id}?locale=${locale.code}`,
460
+ method: 'GET',
461
+ });
462
+ return {
463
+ code: locale.code,
464
+ subject: data.subject,
465
+ body: data.body,
466
+ };
467
+ } catch {
468
+ return null;
469
+ }
470
+ })
471
+ );
472
+
473
+ const validTranslations = allTranslations.filter(
474
+ (t) => t !== null && t.subject && t.body
475
+ );
476
+
477
+ return {
478
+ slug: template.slug,
479
+ translations: validTranslations,
480
+ variables: template.mail_var.map((v) => v.name),
481
+ };
482
+ })
483
+ );
484
+
485
+ const blob = new Blob([JSON.stringify(exportData, null, 2)], {
486
+ type: 'application/json',
487
+ });
488
+ const url = URL.createObjectURL(blob);
489
+ const a = document.createElement('a');
490
+ a.href = url;
491
+ a.download = `mail-templates-${new Date().toISOString().split('T')[0]}.json`;
492
+ document.body.appendChild(a);
493
+ a.click();
494
+ document.body.removeChild(a);
495
+ URL.revokeObjectURL(url);
496
+
497
+ toast.success(t('exportSuccess'));
498
+ } catch (error) {
499
+ console.error('Error exporting templates:', error);
500
+ toast.error(t('exportError'));
501
+ }
502
+ };
503
+
504
+ const handleDownloadHTML = async (template: Mail) => {
505
+ try {
506
+ const response = await request({
507
+ url: `/mail/${template.mail_id || template.id}/html?locale=${template.locale.code}`,
508
+ method: 'GET',
509
+ });
510
+
511
+ const htmlContent =
512
+ typeof response.data === 'string'
513
+ ? response.data
514
+ : JSON.stringify(response.data);
515
+ const blob = new Blob([htmlContent], { type: 'text/html' });
516
+ const url = URL.createObjectURL(blob);
517
+ const a = document.createElement('a');
518
+ a.href = url;
519
+ a.download = `${template.slug}-${template.locale.code}.html`;
520
+ document.body.appendChild(a);
521
+ a.click();
522
+ document.body.removeChild(a);
523
+ URL.revokeObjectURL(url);
524
+
525
+ toast.success(t('htmlDownloadSuccess'));
526
+ } catch (error) {
527
+ console.error('Error downloading HTML:', error);
528
+ toast.error(t('htmlDownloadError'));
529
+ }
530
+ };
531
+
532
+ const downloadYAML = async (template: Mail) => {
533
+ try {
534
+ const allTranslations = await Promise.all(
535
+ locales.map(async (locale) => {
536
+ try {
537
+ const { data } = await request<Mail>({
538
+ url: `/mail/${template.mail_id || template.id}?locale=${locale.code}`,
539
+ method: 'GET',
540
+ });
541
+ return {
542
+ code: locale.code,
543
+ subject: data.subject,
544
+ body: data.body,
545
+ };
546
+ } catch {
547
+ return null;
548
+ }
549
+ })
550
+ );
551
+
552
+ const validTranslations = allTranslations.filter(
553
+ (t) => t !== null && t.subject && t.body
554
+ );
555
+
556
+ let yaml = `- slug: ${template.slug}\n`;
557
+ if (validTranslations.length > 0) {
558
+ yaml += ' subject:\n';
559
+ validTranslations.forEach((translation) => {
560
+ yaml += ` ${translation?.code}: ${translation?.subject}\n`;
561
+ });
562
+ }
563
+
564
+ if (validTranslations.length > 0) {
565
+ yaml += ' body:\n';
566
+ validTranslations.forEach((translation) => {
567
+ yaml += ` ${translation?.code}: |\n`;
568
+ const bodyLines = translation?.body.split('\n');
569
+ bodyLines?.forEach((line) => {
570
+ yaml += ` ${line}\n`;
571
+ });
572
+ });
573
+ }
574
+
575
+ if (formData.variables.length > 0) {
576
+ yaml += ' relations:\n';
577
+ yaml += ' mail_var:\n';
578
+ formData.variables.forEach((v) => {
579
+ yaml += ` - name: ${v}\n`;
580
+ });
581
+ }
582
+
583
+ const blob = new Blob([yaml], { type: 'text/yaml' });
584
+ const url = URL.createObjectURL(blob);
585
+ const a = document.createElement('a');
586
+ a.href = url;
587
+ a.download = `${template.slug}.yaml`;
588
+ document.body.appendChild(a);
589
+ a.click();
590
+ document.body.removeChild(a);
591
+ URL.revokeObjectURL(url);
592
+ } catch (error) {
593
+ console.error('Error downloading YAML:', error);
594
+ toast.error(t('yamlDownloadError'));
595
+ }
596
+ };
597
+
598
+ const replaceVariablesInHTML = (html: string) => {
599
+ let result = html;
600
+ Object.entries(variableTestValues).forEach(([variable, value]) => {
601
+ if (value) {
602
+ const regex = new RegExp(`{{\\s*${variable}\\s*}}`, 'g');
603
+ result = result.replace(regex, value);
604
+ }
605
+ });
606
+ return result;
607
+ };
608
+
609
+ const renderHTMLPreview = (html: string) => {
610
+ const processedHtml = replaceVariablesInHTML(html);
611
+ return `
612
+ <!DOCTYPE html>
613
+ <html>
614
+ <head>
615
+ <meta charset="UTF-8">
616
+ <style>
617
+ body {
618
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
619
+ line-height: 1.6;
620
+ color: #333;
621
+ max-width: 600px;
622
+ margin: 0 auto;
623
+ padding: 20px;
624
+ }
625
+ a {
626
+ color: #0066cc;
627
+ text-decoration: none;
628
+ }
629
+ a:hover {
630
+ text-decoration: underline;
631
+ }
632
+ </style>
633
+ </head>
634
+ <body>
635
+ ${processedHtml}
636
+ </body>
637
+ </html>
638
+ `;
639
+ };
640
+
641
+ const handleImportClick = () => {
642
+ const input = document.createElement('input');
643
+ input.type = 'file';
644
+ input.accept = 'application/json';
645
+ input.onchange = async (e: any) => {
646
+ const file = e.target?.files?.[0];
647
+ if (file) {
648
+ setImportFile(file);
649
+ await processImportFile(file, false);
650
+ }
651
+ };
652
+ input.click();
653
+ };
654
+
655
+ const processImportFile = async (file: File, overwrite: boolean = false) => {
656
+ try {
657
+ const text = await file.text();
658
+ const data = JSON.parse(text);
659
+
660
+ const response = await request<ImportResponse>({
661
+ url: '/mail/import',
662
+ method: 'POST',
663
+ data: {
664
+ data,
665
+ overwrite,
666
+ },
667
+ });
668
+
669
+ if (response.data?.conflicts && response.data.conflicts.length > 0) {
670
+ setConflictingSlugs(response.data.conflicts);
671
+ setIsConflictDialogOpen(true);
672
+ } else {
673
+ toast.success(t('importSuccess'));
674
+ refetch();
675
+ setImportFile(null);
676
+ }
677
+ } catch (error: any) {
678
+ console.error('Error importing templates:', error);
679
+ const message = error?.response?.data?.message || t('importError');
680
+ toast.error(message);
681
+ }
682
+ };
683
+
684
+ const handleConfirmOverwrite = async () => {
685
+ if (importFile) {
686
+ setIsConflictDialogOpen(false);
687
+ await processImportFile(importFile, true);
688
+ }
689
+ };
690
+
691
+ return (
692
+ <div className="flex flex-col h-screen px-4">
693
+ <PageHeader
694
+ breadcrumbs={[
695
+ { label: t('breadcrumbHome'), href: '/' },
696
+ { label: t('breadcrumbTitle') },
697
+ ]}
698
+ actions={[
699
+ ...(selectedTemplates.size > 0
700
+ ? [
701
+ {
702
+ label: t('exportSelected'),
703
+ onClick: handleExportSelected,
704
+ variant: 'outline' as const,
705
+ },
706
+ ]
707
+ : []),
708
+ {
709
+ label: t('import'),
710
+ onClick: handleImportClick,
711
+ variant: 'outline' as const,
712
+ },
713
+ {
714
+ label: t('newTemplate'),
715
+ onClick: () => handleCreate(),
716
+ variant: 'default' as const,
717
+ },
718
+ ]}
719
+ title={t('title')}
720
+ description={t('description')}
721
+ />
722
+
723
+ <Table>
724
+ <TableHeader>
725
+ <TableRow>
726
+ <TableHead className="w-12">
727
+ <Checkbox
728
+ checked={
729
+ selectedTemplates.size === templates.length &&
730
+ templates.length > 0
731
+ }
732
+ onCheckedChange={handleToggleAll}
733
+ />
734
+ </TableHead>
735
+ <TableHead>{t('tableSlug')}</TableHead>
736
+ <TableHead>{t('tableSubject')}</TableHead>
737
+ <TableHead>{t('tableVariables')}</TableHead>
738
+ <TableHead>{t('tableUpdated')}</TableHead>
739
+ <TableHead className="text-right">{t('tableActions')}</TableHead>
740
+ </TableRow>
741
+ </TableHeader>
742
+ <TableBody>
743
+ {templates.map((template) => (
744
+ <TableRow
745
+ key={template.id}
746
+ onDoubleClick={() => handleEdit(template)}
747
+ className="cursor-pointer"
748
+ >
749
+ <TableCell onClick={(e) => e.stopPropagation()}>
750
+ <Checkbox
751
+ checked={selectedTemplates.has(
752
+ template.mail_id || template.id
753
+ )}
754
+ onCheckedChange={() =>
755
+ handleToggleTemplate(template.mail_id || template.id)
756
+ }
757
+ />
758
+ </TableCell>
759
+ <TableCell className="font-mono text-sm">
760
+ {template.slug}
761
+ </TableCell>
762
+ <TableCell>{template.subject}</TableCell>
763
+ <TableCell>
764
+ <div className="flex flex-wrap gap-1">
765
+ {template.mail_var.map((v, index) => (
766
+ <Badge
767
+ key={`${template.id}-${v.id}-${index}`}
768
+ variant="secondary"
769
+ className="text-xs"
770
+ >
771
+ {v.name}
772
+ </Badge>
773
+ ))}
774
+ </div>
775
+ </TableCell>
776
+ <TableCell className="text-muted-foreground">
777
+ {formatDate(
778
+ template.updated_at,
779
+ getSettingValue,
780
+ currentLocaleCode
781
+ )}
782
+ </TableCell>
783
+ <TableCell className="text-right">
784
+ <div className="flex justify-end gap-2">
785
+ <Button
786
+ variant="ghost"
787
+ size="icon"
788
+ onClick={(e) => {
789
+ e.stopPropagation();
790
+ handleDownloadHTML(template);
791
+ }}
792
+ title={t('downloadHTML')}
793
+ >
794
+ <FileDown className="h-4 w-4" />
795
+ </Button>
796
+ <Button
797
+ variant="ghost"
798
+ size="icon"
799
+ onClick={(e) => {
800
+ e.stopPropagation();
801
+ handleEdit(template);
802
+ }}
803
+ >
804
+ <Pencil className="h-4 w-4" />
805
+ </Button>
806
+ <Button
807
+ variant="ghost"
808
+ size="icon"
809
+ onClick={(e) => {
810
+ e.stopPropagation();
811
+ handleDelete(Number(template.mail_id || template.id));
812
+ }}
813
+ >
814
+ <Trash2 className="h-4 w-4 text-destructive" />
815
+ </Button>
816
+ </div>
817
+ </TableCell>
818
+ </TableRow>
819
+ ))}
820
+ </TableBody>
821
+ </Table>
822
+
823
+ <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
824
+ <DialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto">
825
+ <DialogHeader>
826
+ <DialogTitle>
827
+ {editMode ? t('editTemplate') : t('newTemplateTitle')}
828
+ </DialogTitle>
829
+ <DialogDescription>
830
+ {editMode
831
+ ? t('editTemplateDescription')
832
+ : t('newTemplateDescription')}
833
+ </DialogDescription>
834
+ </DialogHeader>
835
+ <form onSubmit={handleSubmit} className="space-y-6">
836
+ <div className="grid grid-cols-2 gap-6">
837
+ <div className="space-y-4 overflow-y-auto max-h-[calc(90vh-200px)] pr-4">
838
+ <div className="space-y-2">
839
+ <Label htmlFor="slug">{t('slugLabel')}</Label>
840
+ <Input
841
+ id="slug"
842
+ value={formData.slug}
843
+ onChange={(e) =>
844
+ setFormData({ ...formData, slug: e.target.value })
845
+ }
846
+ placeholder={t('slugPlaceholder')}
847
+ required
848
+ />
849
+ </div>
850
+
851
+ <div className="space-y-2">
852
+ <Label htmlFor="subject">{t('subjectLabel')}</Label>
853
+ <Input
854
+ id="subject"
855
+ value={formData.subject}
856
+ onChange={(e) =>
857
+ setFormData({ ...formData, subject: e.target.value })
858
+ }
859
+ placeholder={t('subjectPlaceholder')}
860
+ required
861
+ />
862
+ </div>
863
+
864
+ <div className="space-y-2">
865
+ <Label htmlFor="variables">{t('variablesLabel')}</Label>
866
+ <TagsInput
867
+ id="variables"
868
+ value={formData.variables}
869
+ onChange={(val) =>
870
+ setFormData({ ...formData, variables: val })
871
+ }
872
+ placeholder={t('variablesPlaceholder')}
873
+ />
874
+ <p className="text-xs text-muted-foreground">
875
+ {t('variablesHelp')}
876
+ </p>
877
+ </div>
878
+
879
+ <div className="space-y-2">
880
+ <Label htmlFor="body">{t('bodyLabel')}</Label>
881
+ <RichTextEditor
882
+ key={selectedLocaleCode}
883
+ value={formData.body}
884
+ onChange={(value) =>
885
+ setFormData({ ...formData, body: value })
886
+ }
887
+ />
888
+ </div>
889
+ </div>
890
+
891
+ <div className="space-y-4">
892
+ <div className="absolute top-4 right-8 space-y-2">
893
+ <Label htmlFor="locale-select">{t('localeLabel')}</Label>
894
+ <Select
895
+ value={selectedLocaleCode}
896
+ onValueChange={handleLocaleChange}
897
+ >
898
+ <SelectTrigger id="locale-select" className="bg-background">
899
+ <SelectValue />
900
+ </SelectTrigger>
901
+ <SelectContent>
902
+ {locales.map((locale) => (
903
+ <SelectItem key={locale.code} value={locale.code}>
904
+ {locale.name}
905
+ </SelectItem>
906
+ ))}
907
+ </SelectContent>
908
+ </Select>
909
+ </div>
910
+
911
+ <div className="space-y-2">
912
+ <Label>{t('preview')}</Label>
913
+ <div className="rounded-lg border bg-white overflow-auto max-h-[calc(90vh-280px)]">
914
+ <iframe
915
+ srcDoc={renderHTMLPreview(formData.body)}
916
+ className="w-full min-h-[500px] border-0"
917
+ title="Email Preview"
918
+ style={{ colorScheme: 'light' }}
919
+ />
920
+ </div>
921
+ </div>
922
+
923
+ {formData.variables.length > 0 && (
924
+ <div className="mt-4">
925
+ <h4 className="mb-2 text-sm font-semibold">
926
+ {t('availableVariables')}
927
+ </h4>
928
+ <div className="space-y-2 grid grid-cols-2">
929
+ {formData.variables.map((v, idx) => (
930
+ <div key={idx} className="flex items-center gap-2">
931
+ <Badge
932
+ variant="outline"
933
+ className="font-mono min-w-[120px]"
934
+ >
935
+ {'{{' + v + '}}'}
936
+ </Badge>
937
+ <Input
938
+ placeholder={t('testValuePlaceholder')}
939
+ value={variableTestValues[v] || ''}
940
+ onChange={(e) =>
941
+ setVariableTestValues({
942
+ ...variableTestValues,
943
+ [v]: e.target.value,
944
+ })
945
+ }
946
+ className="flex-1"
947
+ />
948
+ </div>
949
+ ))}
950
+ </div>
951
+ </div>
952
+ )}
953
+ </div>
954
+ </div>
955
+
956
+ <div className="flex justify-between items-center pt-4 border-t">
957
+ <div className="flex gap-2">
958
+ <Button
959
+ type="button"
960
+ variant="outline"
961
+ size="sm"
962
+ onClick={() => {
963
+ if (selectedTemplate) {
964
+ downloadYAML(selectedTemplate);
965
+ }
966
+ }}
967
+ >
968
+ <Download className="mr-2 h-4 w-4" />
969
+ {t('downloadYAML')}
970
+ </Button>
971
+ <Button
972
+ type="button"
973
+ variant="outline"
974
+ size="sm"
975
+ onClick={handleGenerateMigration}
976
+ >
977
+ <Database className="mr-2 h-4 w-4" />
978
+ {t('generateMigration')}
979
+ </Button>
980
+ <Button
981
+ type="button"
982
+ variant="outline"
983
+ size="sm"
984
+ onClick={handleSendTestEmail}
985
+ >
986
+ <MailIcon className="mr-2 h-4 w-4" />
987
+ {t('sendTestEmail')}
988
+ </Button>
989
+ </div>
990
+ <div className="flex gap-3">
991
+ <Button
992
+ type="button"
993
+ variant="outline"
994
+ onClick={() => setIsDialogOpen(false)}
995
+ >
996
+ {t('cancel')}
997
+ </Button>
998
+ <Button type="submit">
999
+ {editMode ? t('saveChanges') : t('createTemplate')}
1000
+ </Button>
1001
+ </div>
1002
+ </div>
1003
+ </form>
1004
+ </DialogContent>
1005
+ </Dialog>
1006
+
1007
+ <Dialog
1008
+ open={isTestEmailDialogOpen}
1009
+ onOpenChange={setIsTestEmailDialogOpen}
1010
+ >
1011
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
1012
+ <DialogHeader>
1013
+ <DialogTitle>{t('sendTestEmailTitle')}</DialogTitle>
1014
+ <DialogDescription>
1015
+ {t('sendTestEmailDescription')}
1016
+ </DialogDescription>
1017
+ </DialogHeader>
1018
+ <div className="space-y-6">
1019
+ <div className="space-y-2">
1020
+ <Label htmlFor="test-email-recipient">
1021
+ {t('recipientLabel')}
1022
+ </Label>
1023
+ <Input
1024
+ id="test-email-recipient"
1025
+ type="email"
1026
+ placeholder={t('recipientPlaceholder')}
1027
+ value={testEmailRecipient}
1028
+ onChange={(e) => setTestEmailRecipient(e.target.value)}
1029
+ required
1030
+ />
1031
+ </div>
1032
+
1033
+ {formData.variables.length > 0 && (
1034
+ <div className="space-y-2">
1035
+ <h4 className="text-sm font-semibold">
1036
+ {t('variableValuesTitle')}
1037
+ </h4>
1038
+ <div className="space-y-2">
1039
+ {formData.variables.map((v, idx) => (
1040
+ <div key={idx} className="flex items-center gap-2">
1041
+ <Badge
1042
+ variant="outline"
1043
+ className="font-mono min-w-[120px]"
1044
+ >
1045
+ {'{{' + v + '}}'}
1046
+ </Badge>
1047
+ <Input
1048
+ placeholder={t('variableValuePlaceholder')}
1049
+ value={testEmailVariableValues[v] || ''}
1050
+ onChange={(e) =>
1051
+ setTestEmailVariableValues({
1052
+ ...testEmailVariableValues,
1053
+ [v]: e.target.value,
1054
+ })
1055
+ }
1056
+ className="flex-1"
1057
+ />
1058
+ </div>
1059
+ ))}
1060
+ </div>
1061
+ </div>
1062
+ )}
1063
+
1064
+ <div className="space-y-2">
1065
+ <Label>{t('emailPreview')}</Label>
1066
+ <div className="rounded-lg border bg-white overflow-hidden max-h-[400px]">
1067
+ <iframe
1068
+ srcDoc={renderHTMLPreview(
1069
+ Object.entries(testEmailVariableValues).reduce(
1070
+ (html, [variable, value]) => {
1071
+ if (value) {
1072
+ const regex = new RegExp(
1073
+ `{{\\s*${variable}\\s*}}`,
1074
+ 'g'
1075
+ );
1076
+ return html.replace(regex, value);
1077
+ }
1078
+ return html;
1079
+ },
1080
+ formData.body
1081
+ )
1082
+ )}
1083
+ className="w-full min-h-[400px] border-0"
1084
+ title="Email Test Preview"
1085
+ style={{ colorScheme: 'light' }}
1086
+ />
1087
+ </div>
1088
+ </div>
1089
+ </div>
1090
+ <div className="flex justify-end gap-3 pt-4">
1091
+ <Button
1092
+ type="button"
1093
+ variant="outline"
1094
+ onClick={() => setIsTestEmailDialogOpen(false)}
1095
+ >
1096
+ {t('cancel')}
1097
+ </Button>
1098
+ <Button
1099
+ type="button"
1100
+ onClick={handleConfirmSendTestEmail}
1101
+ disabled={!testEmailRecipient}
1102
+ >
1103
+ <MailIcon className="mr-2 h-4 w-4" />
1104
+ {t('sendEmail')}
1105
+ </Button>
1106
+ </div>
1107
+ </DialogContent>
1108
+ </Dialog>
1109
+
1110
+ <AlertDialog
1111
+ open={isDeleteDialogOpen}
1112
+ onOpenChange={setIsDeleteDialogOpen}
1113
+ >
1114
+ <AlertDialogContent>
1115
+ <AlertDialogHeader>
1116
+ <AlertDialogTitle>{t('deleteConfirmTitle')}</AlertDialogTitle>
1117
+ <AlertDialogDescription>
1118
+ {t('deleteConfirmDescription')}
1119
+ </AlertDialogDescription>
1120
+ </AlertDialogHeader>
1121
+ <AlertDialogFooter>
1122
+ <AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
1123
+ <AlertDialogAction
1124
+ onClick={confirmDelete}
1125
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
1126
+ >
1127
+ {t('delete')}
1128
+ </AlertDialogAction>
1129
+ </AlertDialogFooter>
1130
+ </AlertDialogContent>
1131
+ </AlertDialog>
1132
+
1133
+ <AlertDialog
1134
+ open={isConflictDialogOpen}
1135
+ onOpenChange={setIsConflictDialogOpen}
1136
+ >
1137
+ <AlertDialogContent>
1138
+ <AlertDialogHeader>
1139
+ <AlertDialogTitle>{t('importConflictTitle')}</AlertDialogTitle>
1140
+ <AlertDialogDescription>
1141
+ {t('importConflictDescription')}
1142
+ </AlertDialogDescription>
1143
+ </AlertDialogHeader>
1144
+ <div className="py-4">
1145
+ <p className="text-sm font-medium mb-2">{t('conflictingSlugs')}:</p>
1146
+ <div className="space-y-1">
1147
+ {conflictingSlugs.map((slug, index) => (
1148
+ <div key={index} className="flex items-center gap-2">
1149
+ <Badge variant="destructive" className="font-mono text-xs">
1150
+ {slug}
1151
+ </Badge>
1152
+ </div>
1153
+ ))}
1154
+ </div>
1155
+ </div>
1156
+ <AlertDialogFooter>
1157
+ <AlertDialogCancel
1158
+ onClick={() => {
1159
+ setIsConflictDialogOpen(false);
1160
+ setImportFile(null);
1161
+ setConflictingSlugs([]);
1162
+ }}
1163
+ >
1164
+ {t('cancel')}
1165
+ </AlertDialogCancel>
1166
+ <AlertDialogAction
1167
+ onClick={handleConfirmOverwrite}
1168
+ className="bg-amber-600 text-white hover:bg-amber-700"
1169
+ >
1170
+ {t('overwrite')}
1171
+ </AlertDialogAction>
1172
+ </AlertDialogFooter>
1173
+ </AlertDialogContent>
1174
+ </AlertDialog>
1175
+ </div>
1176
+ );
1177
+ }