@jhits/plugin-newsletter 0.0.15 → 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.
Files changed (90) hide show
  1. package/dist/api/email-utils.d.ts.map +1 -1
  2. package/dist/api/email-utils.js +45 -4
  3. package/dist/api/handlers/newsletters.d.ts.map +1 -1
  4. package/dist/api/handlers/newsletters.js +33 -16
  5. package/dist/api/handlers/send-newsletter.d.ts.map +1 -1
  6. package/dist/api/handlers/send-newsletter.js +54 -6
  7. package/dist/api/handlers/settings.d.ts.map +1 -1
  8. package/dist/api/handlers/settings.js +51 -1
  9. package/dist/index.d.ts +27 -10
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +15 -122
  12. package/dist/lib/blocks/BlockRenderer.d.ts.map +1 -1
  13. package/dist/lib/blocks/BlockRenderer.js +14 -2
  14. package/dist/lib/email/EmailRenderer.d.ts +1 -0
  15. package/dist/lib/email/EmailRenderer.d.ts.map +1 -1
  16. package/dist/lib/email/EmailRenderer.js +31 -19
  17. package/dist/lib/utils/config-resolver.d.ts +33 -0
  18. package/dist/lib/utils/config-resolver.d.ts.map +1 -0
  19. package/dist/lib/utils/config-resolver.js +47 -0
  20. package/dist/registry/BlockRegistry.d.ts +9 -1
  21. package/dist/registry/BlockRegistry.d.ts.map +1 -1
  22. package/dist/registry/BlockRegistry.js +126 -8
  23. package/dist/state/EditorContext.d.ts +11 -1
  24. package/dist/state/EditorContext.d.ts.map +1 -1
  25. package/dist/state/EditorContext.js +23 -5
  26. package/dist/state/types.d.ts +12 -0
  27. package/dist/state/types.d.ts.map +1 -1
  28. package/dist/types/block.d.ts +9 -0
  29. package/dist/types/block.d.ts.map +1 -1
  30. package/dist/types/newsletter.d.ts +4 -0
  31. package/dist/types/newsletter.d.ts.map +1 -1
  32. package/dist/views/CanvasEditor/BlockWrapper.d.ts.map +1 -1
  33. package/dist/views/CanvasEditor/BlockWrapper.js +24 -3
  34. package/dist/views/CanvasEditor/CanvasEditorView.d.ts.map +1 -1
  35. package/dist/views/CanvasEditor/CanvasEditorView.js +77 -17
  36. package/dist/views/CanvasEditor/EditorBody.d.ts.map +1 -1
  37. package/dist/views/CanvasEditor/EditorBody.js +1 -1
  38. package/dist/views/CanvasEditor/components/EditorCanvas.d.ts.map +1 -1
  39. package/dist/views/CanvasEditor/components/EditorCanvas.js +158 -100
  40. package/dist/views/CanvasEditor/components/EditorSidebar.d.ts +3 -1
  41. package/dist/views/CanvasEditor/components/EditorSidebar.d.ts.map +1 -1
  42. package/dist/views/CanvasEditor/components/EditorSidebar.js +3 -3
  43. package/dist/views/CanvasEditor/hooks/useRegisteredBlocks.d.ts +1 -1
  44. package/dist/views/CanvasEditor/hooks/useRegisteredBlocks.d.ts.map +1 -1
  45. package/dist/views/CanvasEditor/hooks/useRegisteredBlocks.js +6 -40
  46. package/dist/views/NewsletterManager.d.ts.map +1 -1
  47. package/dist/views/NewsletterManager.js +87 -5
  48. package/dist/views/components/DomainPromptModal.d.ts +13 -0
  49. package/dist/views/components/DomainPromptModal.d.ts.map +1 -0
  50. package/dist/views/components/DomainPromptModal.js +58 -0
  51. package/dist/views/components/NewsletterCard.d.ts +16 -0
  52. package/dist/views/components/NewsletterCard.d.ts.map +1 -0
  53. package/dist/views/components/NewsletterCard.js +94 -0
  54. package/dist/views/components/NewsletterGrid.d.ts +16 -0
  55. package/dist/views/components/NewsletterGrid.d.ts.map +1 -0
  56. package/dist/views/components/NewsletterGrid.js +13 -0
  57. package/dist/views/components/SendNewsletterModal.d.ts.map +1 -1
  58. package/dist/views/components/SendNewsletterModal.js +91 -22
  59. package/dist/views/components/SmtpSettingsModal.d.ts.map +1 -1
  60. package/dist/views/components/SmtpSettingsModal.js +10 -0
  61. package/dist/views/components/TestEmailModal.d.ts.map +1 -1
  62. package/dist/views/components/TestEmailModal.js +86 -17
  63. package/package.json +53 -9
  64. package/src/api/email-utils.ts +53 -4
  65. package/src/api/handlers/newsletters.ts +40 -20
  66. package/src/api/handlers/send-newsletter.ts +65 -6
  67. package/src/api/handlers/settings.ts +60 -2
  68. package/src/index.tsx +49 -155
  69. package/src/lib/blocks/BlockRenderer.tsx +16 -2
  70. package/src/lib/email/EmailRenderer.tsx +31 -20
  71. package/src/lib/utils/config-resolver.ts +71 -0
  72. package/src/registry/BlockRegistry.tsx +255 -0
  73. package/src/state/EditorContext.tsx +43 -8
  74. package/src/state/types.ts +16 -0
  75. package/src/types/block.ts +10 -0
  76. package/src/types/newsletter.ts +5 -0
  77. package/src/views/CanvasEditor/BlockWrapper.tsx +27 -2
  78. package/src/views/CanvasEditor/CanvasEditorView.tsx +142 -61
  79. package/src/views/CanvasEditor/EditorBody.tsx +17 -13
  80. package/src/views/CanvasEditor/components/EditorCanvas.tsx +178 -115
  81. package/src/views/CanvasEditor/components/EditorSidebar.tsx +57 -2
  82. package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +6 -45
  83. package/src/views/NewsletterManager.tsx +164 -6
  84. package/src/views/components/DomainPromptModal.tsx +160 -0
  85. package/src/views/components/NewsletterCard.tsx +212 -0
  86. package/src/views/components/NewsletterGrid.tsx +48 -0
  87. package/src/views/components/SendNewsletterModal.tsx +270 -184
  88. package/src/views/components/SmtpSettingsModal.tsx +11 -0
  89. package/src/views/components/TestEmailModal.tsx +235 -149
  90. package/src/registry/BlockRegistry.ts +0 -53
@@ -6,11 +6,13 @@
6
6
  'use client';
7
7
 
8
8
  import React, { useState, useEffect } from 'react';
9
- import { Plus, Mail, Calendar, Trash2, Edit2, Settings2, Sparkles, CheckCircle2, Clock, Send, Users } from 'lucide-react';
9
+ import { Plus, Mail, Calendar, Trash2, Edit2, Settings2, Sparkles, CheckCircle2, Clock, Send, Users, Grid, List } from 'lucide-react';
10
10
  import { NewsletterListItem, NewsletterStatus, SendHistoryEntry } from '../types/newsletter';
11
11
  import { SmtpSettingsModal } from './components/SmtpSettingsModal';
12
12
  import { TestEmailModal } from './components/TestEmailModal';
13
13
  import { SendNewsletterModal } from './components/SendNewsletterModal';
14
+ import { NewsletterGrid } from './components/NewsletterGrid';
15
+ import { NewsletterCard } from './components/NewsletterCard';
14
16
 
15
17
  export interface NewsletterManagerViewProps {
16
18
  siteId: string;
@@ -36,6 +38,8 @@ export function NewsletterManagerView({ siteId, locale }: NewsletterManagerViewP
36
38
  const [newsletters, setNewsletters] = useState<NewsletterListItem[]>([]);
37
39
  const [isLoading, setIsLoading] = useState(true);
38
40
  const [statusFilter, setStatusFilter] = useState<NewsletterStatus | 'all'>('all');
41
+ const [primaryLanguage, setPrimaryLanguage] = useState<string>('en');
42
+ const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
39
43
  const [showSmtpModal, setShowSmtpModal] = useState(false);
40
44
  const [showTestEmailModal, setShowTestEmailModal] = useState(false);
41
45
  const [showSendModal, setShowSendModal] = useState(false);
@@ -47,12 +51,34 @@ export function NewsletterManagerView({ siteId, locale }: NewsletterManagerViewP
47
51
  const [welcomeEmailLastUpdated, setWelcomeEmailLastUpdated] = useState<string | null>(null);
48
52
  const [subscriberCount, setSubscriberCount] = useState(0);
49
53
 
54
+ // Hover state for language tooltip
55
+ const [hoveredNewsletter, setHoveredNewsletter] = useState<{ id: string; x: number; y: number } | null>(null);
56
+
57
+ // Fetch primary language from SMTP settings
58
+ useEffect(() => {
59
+ const fetchPrimaryLanguage = async () => {
60
+ try {
61
+ const response = await fetch('/api/plugin-newsletter/smtp', {
62
+ credentials: 'include',
63
+ });
64
+ if (response.ok) {
65
+ const data = await response.json();
66
+ setPrimaryLanguage(data.primaryLanguage || 'en');
67
+ }
68
+ } catch (error) {
69
+ console.error('Failed to fetch SMTP settings:', error);
70
+ }
71
+ };
72
+ fetchPrimaryLanguage();
73
+ }, []);
74
+
50
75
  // Fetch newsletters
51
76
  useEffect(() => {
52
77
  const fetchNewsletters = async () => {
53
78
  try {
54
79
  setIsLoading(true);
55
- const response = await fetch('/api/plugin-newsletter/newsletters', {
80
+ const langParam = primaryLanguage ? `?language=${primaryLanguage}` : '';
81
+ const response = await fetch(`/api/plugin-newsletter/newsletters${langParam}`, {
56
82
  credentials: 'include',
57
83
  });
58
84
  if (!response.ok) {
@@ -67,7 +93,7 @@ export function NewsletterManagerView({ siteId, locale }: NewsletterManagerViewP
67
93
  }
68
94
  };
69
95
  fetchNewsletters();
70
- }, []);
96
+ }, [primaryLanguage]);
71
97
 
72
98
  // Fetch welcome email status
73
99
  useEffect(() => {
@@ -216,6 +242,37 @@ export function NewsletterManagerView({ siteId, locale }: NewsletterManagerViewP
216
242
  });
217
243
  };
218
244
 
245
+ // Format date with time
246
+ const formatDateTime = (dateString: string | undefined) => {
247
+ if (!dateString) return 'Never';
248
+ const date = new Date(dateString);
249
+ return date.toLocaleString(locale, {
250
+ day: 'numeric',
251
+ month: 'short',
252
+ year: 'numeric',
253
+ hour: '2-digit',
254
+ minute: '2-digit',
255
+ });
256
+ };
257
+
258
+ // Get send history for a specific language
259
+ const getSendHistoryForLanguage = (sendHistory: SendHistoryEntry[], language: string) => {
260
+ return sendHistory
261
+ .filter(entry => entry.language === language)
262
+ .sort((a, b) => new Date(b.sentAt).getTime() - new Date(a.sentAt).getTime());
263
+ };
264
+
265
+ // Sort languages: those with send history first (most recent first), then by language code
266
+ const sortLanguages = (langs: string[], sendHistory: SendHistoryEntry[]) => {
267
+ return [...langs].sort((a, b) => {
268
+ const aHasHistory = sendHistory.some(h => h.language === a);
269
+ const bHasHistory = sendHistory.some(h => h.language === b);
270
+ if (aHasHistory && !bHasHistory) return -1;
271
+ if (!aHasHistory && bHasHistory) return 1;
272
+ return a.localeCompare(b);
273
+ });
274
+ };
275
+
219
276
  // Format last sent info
220
277
  const formatLastSent = (sendHistory: SendHistoryEntry[] | undefined) => {
221
278
  if (!sendHistory || sendHistory.length === 0) return null;
@@ -311,6 +368,32 @@ export function NewsletterManagerView({ siteId, locale }: NewsletterManagerViewP
311
368
  <option value="archived">Archived</option>
312
369
  </select>
313
370
 
371
+ {/* View Toggle */}
372
+ <div className="flex items-center bg-dashboard-bg border border-dashboard-border rounded-xl p-1 gap-1">
373
+ <button
374
+ onClick={() => setViewMode('grid')}
375
+ className={`p-2 rounded-lg transition-colors ${
376
+ viewMode === 'grid'
377
+ ? 'bg-primary text-white'
378
+ : 'text-dashboard-text-secondary hover:text-dashboard-text'
379
+ }`}
380
+ title="Grid view"
381
+ >
382
+ <Grid size={16} />
383
+ </button>
384
+ <button
385
+ onClick={() => setViewMode('list')}
386
+ className={`p-2 rounded-lg transition-colors ${
387
+ viewMode === 'list'
388
+ ? 'bg-primary text-white'
389
+ : 'text-dashboard-text-secondary hover:text-dashboard-text'
390
+ }`}
391
+ title="List view"
392
+ >
393
+ <List size={16} />
394
+ </button>
395
+ </div>
396
+
314
397
  {/* Create Button */}
315
398
  <button
316
399
  onClick={handleCreate}
@@ -393,6 +476,15 @@ export function NewsletterManagerView({ siteId, locale }: NewsletterManagerViewP
393
476
  Create Your First Newsletter
394
477
  </button>
395
478
  </div>
479
+ ) : viewMode === 'grid' ? (
480
+ <NewsletterGrid
481
+ newsletters={filteredNewsletters}
482
+ onEdit={handleEdit}
483
+ onSend={handleSend}
484
+ onDelete={handleDelete}
485
+ formatDateTime={formatDateTime}
486
+ locale={locale}
487
+ />
396
488
  ) : (
397
489
  <div className="overflow-x-auto">
398
490
  <table className="w-full text-left border-collapse">
@@ -417,9 +509,22 @@ export function NewsletterManagerView({ siteId, locale }: NewsletterManagerViewP
417
509
  <div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary group-hover:bg-primary group-hover:text-white transition-colors">
418
510
  <Mail size={18} />
419
511
  </div>
420
- <span className="text-sm font-medium text-dashboard-text tracking-tight">
421
- {newsletter.title}
422
- </span>
512
+ <div className="relative">
513
+ <span
514
+ className="text-sm font-medium text-dashboard-text tracking-tight cursor-pointer hover:text-primary transition-colors"
515
+ onMouseEnter={(e) => {
516
+ const rect = e.currentTarget.getBoundingClientRect();
517
+ setHoveredNewsletter({
518
+ id: newsletter.id,
519
+ x: rect.left + rect.width / 2,
520
+ y: rect.bottom + 8
521
+ });
522
+ }}
523
+ onMouseLeave={() => setHoveredNewsletter(null)}
524
+ >
525
+ {newsletter.title}
526
+ </span>
527
+ </div>
423
528
  </div>
424
529
  </td>
425
530
  <td className="px-8 py-5">
@@ -503,6 +608,59 @@ export function NewsletterManagerView({ siteId, locale }: NewsletterManagerViewP
503
608
  subscriberCount={subscriberCount}
504
609
  />
505
610
  )}
611
+
612
+ {/* Fixed Language Tooltip */}
613
+ {hoveredNewsletter && (() => {
614
+ const tooltipNewsletter = newsletters.find(n => n.id === hoveredNewsletter.id);
615
+ if (!tooltipNewsletter?.languages) return null;
616
+ const tooltipLanguages = tooltipNewsletter.languages;
617
+ const tooltipSendHistory = tooltipNewsletter.sendHistory || [];
618
+
619
+ return (
620
+ <div
621
+ className="fixed z-50 w-72 bg-dashboard-card border border-dashboard-border rounded-xl shadow-2xl p-3 pointer-events-none"
622
+ style={{
623
+ left: hoveredNewsletter.x,
624
+ top: hoveredNewsletter.y,
625
+ transform: 'translateX(-50%)'
626
+ }}
627
+ >
628
+ <div className="text-[10px] uppercase tracking-wider text-dashboard-text-secondary font-bold mb-2">
629
+ Translations & Send History
630
+ </div>
631
+ {sortLanguages(Object.keys(tooltipLanguages), tooltipSendHistory).map((lang: string) => {
632
+ const data = tooltipLanguages[lang];
633
+ const langSendHistory = getSendHistoryForLanguage(tooltipSendHistory, lang);
634
+ const lastSend = langSendHistory[0];
635
+
636
+ return (
637
+ <div key={lang} className="py-2 border-b border-dashboard-border last:border-0">
638
+ <div className="flex items-center justify-between">
639
+ <div className="text-[10px] font-black uppercase text-primary">{lang}</div>
640
+ {lastSend && (
641
+ <div className="text-[9px] text-green-600 dark:text-green-400 flex items-center gap-1">
642
+ <Send size={10} />
643
+ Sent
644
+ </div>
645
+ )}
646
+ </div>
647
+ <div className="text-xs text-dashboard-text truncate">{data?.metadata?.subject || 'No subject'}</div>
648
+ {lastSend && (
649
+ <div className="text-[9px] text-dashboard-text-secondary mt-1">
650
+ {formatDateTime(lastSend.sentAt)} • {lastSend.recipientCount} recipients
651
+ </div>
652
+ )}
653
+ {langSendHistory.length > 1 && (
654
+ <div className="text-[9px] text-dashboard-text-secondary mt-0.5">
655
+ + {langSendHistory.length - 1} more send(s)
656
+ </div>
657
+ )}
658
+ </div>
659
+ );
660
+ })}
661
+ </div>
662
+ );
663
+ })()}
506
664
  </>
507
665
  );
508
666
  }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Domain Prompt Modal Component
3
+ * Non-technical modal to ask for the website domain when it's missing in dev mode.
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React, { useState } from 'react';
9
+ import { X, Globe, Save, RefreshCw, CheckCircle2, Info } from 'lucide-react';
10
+
11
+ interface DomainPromptModalProps {
12
+ isOpen: boolean;
13
+ onClose: () => void;
14
+ onSave: (domain: string) => Promise<void>;
15
+ language: string;
16
+ }
17
+
18
+ export function DomainPromptModal({ isOpen, onClose, onSave, language }: DomainPromptModalProps) {
19
+ const [domain, setDomain] = useState('');
20
+ const [isSaving, setIsSaving] = useState(false);
21
+ const [isSuccess, setIsSuccess] = useState(false);
22
+ const [error, setError] = useState<string | null>(null);
23
+
24
+ const languageNames: Record<string, string> = {
25
+ en: 'English',
26
+ nl: 'Dutch',
27
+ sv: 'Swedish',
28
+ };
29
+
30
+ const handleSave = async () => {
31
+ if (!domain.trim()) {
32
+ setError('Please enter a website address');
33
+ return;
34
+ }
35
+
36
+ // Basic URL validation/cleanup
37
+ let cleanDomain = domain.trim().toLowerCase();
38
+ if (cleanDomain.startsWith('http://')) cleanDomain = cleanDomain.replace('http://', '');
39
+ if (cleanDomain.startsWith('https://')) cleanDomain = cleanDomain.replace('https://', '');
40
+ if (cleanDomain.endsWith('/')) cleanDomain = cleanDomain.slice(0, -1);
41
+
42
+ try {
43
+ setIsSaving(true);
44
+ setError(null);
45
+ await onSave(cleanDomain);
46
+ setIsSuccess(true);
47
+ setTimeout(() => {
48
+ onClose();
49
+ }, 1500);
50
+ } catch (err: any) {
51
+ setError(err.message || 'Failed to save domain configuration');
52
+ } finally {
53
+ setIsSaving(false);
54
+ }
55
+ };
56
+
57
+ if (!isOpen) return null;
58
+
59
+ return (
60
+ <div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
61
+ {/* Backdrop */}
62
+ <div
63
+ className="absolute inset-0 bg-black/60 backdrop-blur-md"
64
+ onClick={() => !isSaving && onClose()}
65
+ />
66
+
67
+ {/* Modal */}
68
+ <div className="relative w-full max-w-lg bg-white dark:bg-neutral-900 rounded-[2.5rem] border border-dashboard-border shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-300">
69
+ {/* Header */}
70
+ <div className="px-10 pt-10 pb-6 text-center">
71
+ <div className="w-20 h-20 bg-primary/10 rounded-[2rem] flex items-center justify-center mx-auto mb-6">
72
+ <Globe size={40} className="text-primary" />
73
+ </div>
74
+ <h2 className="text-3xl font-black text-dashboard-text uppercase tracking-tighter leading-none mb-2">
75
+ Website Connection<span className="text-primary">.</span>
76
+ </h2>
77
+ <p className="text-sm text-neutral-500 dark:text-neutral-400 font-medium">
78
+ We need to know where your <span className="font-bold text-dashboard-text">{languageNames[language] || language}</span> website is hosted to ensure links work correctly.
79
+ </p>
80
+ </div>
81
+
82
+ {/* Content */}
83
+ <div className="px-10 pb-10 space-y-6">
84
+ <div className="bg-blue-50/50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-900/20 rounded-2xl p-4 flex gap-3">
85
+ <Info size={18} className="text-blue-500 flex-shrink-0 mt-0.5" />
86
+ <p className="text-xs text-blue-700 dark:text-blue-300 leading-relaxed">
87
+ Since you are in development mode, we couldn't automatically find the domain for this language. Please provide it below.
88
+ </p>
89
+ </div>
90
+
91
+ <div>
92
+ <label className="text-[10px] font-black text-dashboard-text-secondary uppercase tracking-[0.2em] block mb-3 ml-1">
93
+ Your {languageNames[language] || language} Website URL
94
+ </label>
95
+ <div className="relative group">
96
+ <div className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-400 group-focus-within:text-primary transition-colors font-mono text-sm">
97
+ https://
98
+ </div>
99
+ <input
100
+ type="text"
101
+ value={domain}
102
+ onChange={(e) => {
103
+ setDomain(e.target.value);
104
+ setError(null);
105
+ }}
106
+ onKeyDown={(e) => e.key === 'Enter' && handleSave()}
107
+ placeholder="www.yourwebsite.com"
108
+ className="w-full pl-20 pr-6 py-4 bg-dashboard-bg border border-dashboard-border rounded-2xl outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all text-dashboard-text font-medium"
109
+ autoFocus
110
+ />
111
+ </div>
112
+ {error && (
113
+ <p className="text-[10px] text-red-500 font-bold uppercase tracking-widest mt-3 ml-1">
114
+ {error}
115
+ </p>
116
+ )}
117
+ </div>
118
+
119
+ <div className="flex gap-3 pt-2">
120
+ <button
121
+ onClick={onClose}
122
+ disabled={isSaving}
123
+ className="flex-1 px-6 py-4 rounded-full text-[10px] font-black uppercase tracking-widest transition-all bg-dashboard-bg border border-dashboard-border text-dashboard-text hover:bg-dashboard-border active:scale-95"
124
+ >
125
+ Skip for now
126
+ </button>
127
+ <button
128
+ onClick={handleSave}
129
+ disabled={isSaving || isSuccess}
130
+ className={`flex-[2] inline-flex items-center justify-center gap-2 px-6 py-4 rounded-full text-[10px] font-black uppercase tracking-widest transition-all shadow-lg active:scale-95 ${
131
+ isSaving
132
+ ? 'bg-neutral-400 text-white cursor-not-allowed'
133
+ : isSuccess
134
+ ? 'bg-green-600 text-white'
135
+ : 'bg-primary text-white hover:shadow-primary/20'
136
+ }`}
137
+ >
138
+ {isSaving ? (
139
+ <>
140
+ <RefreshCw className="w-4 h-4 animate-spin" />
141
+ Connecting...
142
+ </>
143
+ ) : isSuccess ? (
144
+ <>
145
+ <CheckCircle2 className="w-4 h-4" />
146
+ Saved!
147
+ </>
148
+ ) : (
149
+ <>
150
+ <Save className="w-4 h-4" />
151
+ Link Website
152
+ </>
153
+ )}
154
+ </button>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ );
160
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Newsletter Card Component
3
+ * Modern, distinctive card design for newsletters
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React from 'react';
9
+ import { Trash2, Edit2, Send, Check } from 'lucide-react';
10
+ import { NewsletterListItem, NewsletterStatus } from '../../types/newsletter';
11
+
12
+ interface NewsletterCardProps {
13
+ newsletter: NewsletterListItem;
14
+ onEdit: (id: string) => void;
15
+ onSend: (newsletter: NewsletterListItem) => void;
16
+ onDelete: (id: string, title: string) => void;
17
+ formatDateTime: (dateString: string | undefined) => string;
18
+ locale: string;
19
+ }
20
+
21
+ function getStatusConfig(status: NewsletterStatus) {
22
+ switch (status) {
23
+ case 'sent':
24
+ return {
25
+ label: 'Sent',
26
+ bg: 'bg-green-500',
27
+ text: 'text-green-600 dark:text-green-400',
28
+ };
29
+ case 'scheduled':
30
+ return {
31
+ label: 'Scheduled',
32
+ bg: 'bg-blue-500',
33
+ text: 'text-blue-600 dark:text-blue-400',
34
+ };
35
+ case 'draft':
36
+ return {
37
+ label: 'Draft',
38
+ bg: 'bg-gray-400',
39
+ text: 'text-gray-500 dark:text-gray-400',
40
+ };
41
+ case 'archived':
42
+ return {
43
+ label: 'Archived',
44
+ bg: 'bg-neutral-400',
45
+ text: 'text-neutral-500 dark:text-neutral-400',
46
+ };
47
+ default:
48
+ return {
49
+ label: status,
50
+ bg: 'bg-gray-400',
51
+ text: 'text-gray-500',
52
+ };
53
+ }
54
+ }
55
+
56
+ const LANGUAGE_NAMES: Record<string, string> = {
57
+ en: 'English',
58
+ nl: 'Dutch',
59
+ sv: 'Swedish',
60
+ de: 'German',
61
+ fr: 'French',
62
+ es: 'Spanish',
63
+ it: 'Italian',
64
+ pt: 'Portuguese',
65
+ pl: 'Polish',
66
+ ru: 'Russian',
67
+ ja: 'Japanese',
68
+ zh: 'Chinese',
69
+ ar: 'Arabic',
70
+ tr: 'Turkish',
71
+ };
72
+
73
+ const LANGUAGE_COUNTRY_CODES: Record<string, string> = {
74
+ en: 'gb',
75
+ nl: 'nl',
76
+ sv: 'se',
77
+ de: 'de',
78
+ fr: 'fr',
79
+ es: 'es',
80
+ it: 'it',
81
+ pt: 'pt',
82
+ pl: 'pl',
83
+ ru: 'ru',
84
+ ja: 'jp',
85
+ zh: 'cn',
86
+ ar: 'sa',
87
+ tr: 'tr',
88
+ };
89
+
90
+ const getFlagUrl = (lang: string, size: number = 32) => {
91
+ const countryCode = LANGUAGE_COUNTRY_CODES[lang] || lang;
92
+ return `https://flagcdn.com/${size}x${Math.round(size * 0.75)}/${countryCode}.png`;
93
+ };
94
+
95
+ export function NewsletterCard({
96
+ newsletter,
97
+ onEdit,
98
+ onSend,
99
+ onDelete,
100
+ formatDateTime,
101
+ locale
102
+ }: NewsletterCardProps) {
103
+ const statusConfig = getStatusConfig(newsletter.status);
104
+ const languages = newsletter.availableLanguages || [];
105
+ const sendHistory = newsletter.sendHistory || [];
106
+
107
+ const sentLanguages = [...new Set(sendHistory.map(h => h.language))];
108
+
109
+ return (
110
+ <div className="group relative bg-white dark:bg-neutral-900 rounded-2xl border border-dashboard-border overflow-hidden hover:border-primary/40 hover:shadow-xl hover:shadow-primary/5 transition-all duration-300">
111
+ <div className={`h-1.5 ${statusConfig.bg}`} />
112
+
113
+ <div className="p-5">
114
+ <div className="flex items-center justify-between mb-4">
115
+ <div className="flex items-center gap-2">
116
+ <span className={`text-[10px] font-black uppercase tracking-widest ${statusConfig.text}`}>
117
+ {statusConfig.label}
118
+ </span>
119
+ </div>
120
+
121
+ <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
122
+ <button
123
+ onClick={() => onSend(newsletter)}
124
+ className="p-2 rounded-lg text-neutral-400 hover:text-primary hover:bg-primary/10 transition-all"
125
+ title={newsletter.status === 'sent' ? 'Resend' : 'Send'}
126
+ >
127
+ <Send size={16} />
128
+ </button>
129
+ <button
130
+ onClick={() => onEdit(newsletter.id)}
131
+ className="p-2 rounded-lg text-neutral-400 hover:text-primary hover:bg-primary/10 transition-all"
132
+ title="Edit"
133
+ >
134
+ <Edit2 size={16} />
135
+ </button>
136
+ <button
137
+ onClick={() => onDelete(newsletter.id, newsletter.title)}
138
+ className="p-2 rounded-lg text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all"
139
+ title="Delete"
140
+ >
141
+ <Trash2 size={16} />
142
+ </button>
143
+ </div>
144
+ </div>
145
+
146
+ <h3 className="text-lg font-bold text-dashboard-text mb-4 line-clamp-2 group-hover:text-primary transition-colors">
147
+ {newsletter.title || 'Untitled Newsletter'}
148
+ </h3>
149
+
150
+ <div className="space-y-2">
151
+ {languages.map((lang) => {
152
+ const hasBeenSent = sentLanguages.includes(lang);
153
+ const langHistory = sendHistory.filter(h => h.language === lang);
154
+ const lastLangSend = langHistory[0];
155
+ const langData = newsletter.languages?.[lang];
156
+ const langSubject = langData?.metadata?.subject || newsletter.title;
157
+
158
+ return (
159
+ <div
160
+ key={lang}
161
+ className={`relative group/lang flex items-center justify-between px-3 py-2 rounded-lg border transition-all hover:border-primary/50 ${
162
+ hasBeenSent ? '' : 'bg-dashboard-bg border-dashboard-border'
163
+ }`}
164
+ style={{
165
+ backgroundColor: hasBeenSent ? 'rgba(34, 197, 94, 0.08)' : undefined,
166
+ borderColor: hasBeenSent ? 'rgba(34, 197, 94, 0.3)' : undefined,
167
+ }}
168
+ >
169
+ {hasBeenSent && (
170
+ <div className="absolute -top-2 -right-2 min-w-[20px] h-5 px-1.5 bg-green-500 rounded-full flex items-center justify-center shadow-sm">
171
+ <span className="text-[10px] font-bold text-white">{langHistory.length}</span>
172
+ </div>
173
+ )}
174
+
175
+ <div className="flex items-center gap-3 flex-1 min-w-0">
176
+ <img
177
+ src={getFlagUrl(lang, 28)}
178
+ alt={LANGUAGE_NAMES[lang] || lang}
179
+ className="w-7 h-5 rounded object-cover shrink-0"
180
+ loading="lazy"
181
+ />
182
+ </div>
183
+
184
+ <div className="flex items-center gap-4 text-xs shrink-0">
185
+ {hasBeenSent && lastLangSend ? (
186
+ <>
187
+ <div className="text-dashboard-text-secondary">
188
+ {formatDateTime(lastLangSend.sentAt)}
189
+ </div>
190
+ <div className="text-dashboard-text-secondary">
191
+ {lastLangSend.recipientCount} recipients
192
+ </div>
193
+ </>
194
+ ) : (
195
+ <span className="text-dashboard-text-secondary">Not sent</span>
196
+ )}
197
+ </div>
198
+
199
+ {/* Hover tooltip with subject */}
200
+ <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-neutral-900 dark:bg-neutral-800 text-white text-xs rounded-lg opacity-0 group-hover/lang:opacity-100 transition-opacity pointer-events-none z-10 w-64 shadow-lg">
201
+ <div className="font-bold mb-1">{LANGUAGE_NAMES[lang] || lang.toUpperCase()}</div>
202
+ <div className="text-neutral-300 break-words">{langSubject || 'Untitled'}</div>
203
+ <div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-0 h-0 border-4 border-transparent border-t-neutral-900 dark:border-t-neutral-800" />
204
+ </div>
205
+ </div>
206
+ );
207
+ })}
208
+ </div>
209
+ </div>
210
+ </div>
211
+ );
212
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Newsletter Grid View Component
3
+ * Card-based grid layout for newsletters
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React from 'react';
9
+ import { NewsletterListItem } from '../../types/newsletter';
10
+ import { NewsletterCard } from './NewsletterCard';
11
+
12
+ interface NewsletterGridProps {
13
+ newsletters: NewsletterListItem[];
14
+ onEdit: (id: string) => void;
15
+ onSend: (newsletter: NewsletterListItem) => void;
16
+ onDelete: (id: string, title: string) => void;
17
+ formatDateTime: (dateString: string | undefined) => string;
18
+ locale: string;
19
+ }
20
+
21
+ export function NewsletterGrid({
22
+ newsletters,
23
+ onEdit,
24
+ onSend,
25
+ onDelete,
26
+ formatDateTime,
27
+ locale
28
+ }: NewsletterGridProps) {
29
+ if (newsletters.length === 0) {
30
+ return null;
31
+ }
32
+
33
+ return (
34
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
35
+ {newsletters.map((newsletter) => (
36
+ <NewsletterCard
37
+ key={newsletter.id}
38
+ newsletter={newsletter}
39
+ onEdit={onEdit}
40
+ onSend={onSend}
41
+ onDelete={onDelete}
42
+ formatDateTime={formatDateTime}
43
+ locale={locale}
44
+ />
45
+ ))}
46
+ </div>
47
+ );
48
+ }