@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.
- package/dist/api/email-utils.d.ts.map +1 -1
- package/dist/api/email-utils.js +45 -4
- package/dist/api/handlers/newsletters.d.ts.map +1 -1
- package/dist/api/handlers/newsletters.js +33 -16
- package/dist/api/handlers/send-newsletter.d.ts.map +1 -1
- package/dist/api/handlers/send-newsletter.js +54 -6
- package/dist/api/handlers/settings.d.ts.map +1 -1
- package/dist/api/handlers/settings.js +51 -1
- package/dist/index.d.ts +27 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -122
- package/dist/lib/blocks/BlockRenderer.d.ts.map +1 -1
- package/dist/lib/blocks/BlockRenderer.js +14 -2
- package/dist/lib/email/EmailRenderer.d.ts +1 -0
- package/dist/lib/email/EmailRenderer.d.ts.map +1 -1
- package/dist/lib/email/EmailRenderer.js +31 -19
- package/dist/lib/utils/config-resolver.d.ts +33 -0
- package/dist/lib/utils/config-resolver.d.ts.map +1 -0
- package/dist/lib/utils/config-resolver.js +47 -0
- package/dist/registry/BlockRegistry.d.ts +9 -1
- package/dist/registry/BlockRegistry.d.ts.map +1 -1
- package/dist/registry/BlockRegistry.js +126 -8
- package/dist/state/EditorContext.d.ts +11 -1
- package/dist/state/EditorContext.d.ts.map +1 -1
- package/dist/state/EditorContext.js +23 -5
- package/dist/state/types.d.ts +12 -0
- package/dist/state/types.d.ts.map +1 -1
- package/dist/types/block.d.ts +9 -0
- package/dist/types/block.d.ts.map +1 -1
- package/dist/types/newsletter.d.ts +4 -0
- package/dist/types/newsletter.d.ts.map +1 -1
- package/dist/views/CanvasEditor/BlockWrapper.d.ts.map +1 -1
- package/dist/views/CanvasEditor/BlockWrapper.js +24 -3
- package/dist/views/CanvasEditor/CanvasEditorView.d.ts.map +1 -1
- package/dist/views/CanvasEditor/CanvasEditorView.js +77 -17
- package/dist/views/CanvasEditor/EditorBody.d.ts.map +1 -1
- package/dist/views/CanvasEditor/EditorBody.js +1 -1
- package/dist/views/CanvasEditor/components/EditorCanvas.d.ts.map +1 -1
- package/dist/views/CanvasEditor/components/EditorCanvas.js +158 -100
- package/dist/views/CanvasEditor/components/EditorSidebar.d.ts +3 -1
- package/dist/views/CanvasEditor/components/EditorSidebar.d.ts.map +1 -1
- package/dist/views/CanvasEditor/components/EditorSidebar.js +3 -3
- package/dist/views/CanvasEditor/hooks/useRegisteredBlocks.d.ts +1 -1
- package/dist/views/CanvasEditor/hooks/useRegisteredBlocks.d.ts.map +1 -1
- package/dist/views/CanvasEditor/hooks/useRegisteredBlocks.js +6 -40
- package/dist/views/NewsletterManager.d.ts.map +1 -1
- package/dist/views/NewsletterManager.js +87 -5
- package/dist/views/components/DomainPromptModal.d.ts +13 -0
- package/dist/views/components/DomainPromptModal.d.ts.map +1 -0
- package/dist/views/components/DomainPromptModal.js +58 -0
- package/dist/views/components/NewsletterCard.d.ts +16 -0
- package/dist/views/components/NewsletterCard.d.ts.map +1 -0
- package/dist/views/components/NewsletterCard.js +94 -0
- package/dist/views/components/NewsletterGrid.d.ts +16 -0
- package/dist/views/components/NewsletterGrid.d.ts.map +1 -0
- package/dist/views/components/NewsletterGrid.js +13 -0
- package/dist/views/components/SendNewsletterModal.d.ts.map +1 -1
- package/dist/views/components/SendNewsletterModal.js +91 -22
- package/dist/views/components/SmtpSettingsModal.d.ts.map +1 -1
- package/dist/views/components/SmtpSettingsModal.js +10 -0
- package/dist/views/components/TestEmailModal.d.ts.map +1 -1
- package/dist/views/components/TestEmailModal.js +86 -17
- package/package.json +53 -9
- package/src/api/email-utils.ts +53 -4
- package/src/api/handlers/newsletters.ts +40 -20
- package/src/api/handlers/send-newsletter.ts +65 -6
- package/src/api/handlers/settings.ts +60 -2
- package/src/index.tsx +49 -155
- package/src/lib/blocks/BlockRenderer.tsx +16 -2
- package/src/lib/email/EmailRenderer.tsx +31 -20
- package/src/lib/utils/config-resolver.ts +71 -0
- package/src/registry/BlockRegistry.tsx +255 -0
- package/src/state/EditorContext.tsx +43 -8
- package/src/state/types.ts +16 -0
- package/src/types/block.ts +10 -0
- package/src/types/newsletter.ts +5 -0
- package/src/views/CanvasEditor/BlockWrapper.tsx +27 -2
- package/src/views/CanvasEditor/CanvasEditorView.tsx +142 -61
- package/src/views/CanvasEditor/EditorBody.tsx +17 -13
- package/src/views/CanvasEditor/components/EditorCanvas.tsx +178 -115
- package/src/views/CanvasEditor/components/EditorSidebar.tsx +57 -2
- package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +6 -45
- package/src/views/NewsletterManager.tsx +164 -6
- package/src/views/components/DomainPromptModal.tsx +160 -0
- package/src/views/components/NewsletterCard.tsx +212 -0
- package/src/views/components/NewsletterGrid.tsx +48 -0
- package/src/views/components/SendNewsletterModal.tsx +270 -184
- package/src/views/components/SmtpSettingsModal.tsx +11 -0
- package/src/views/components/TestEmailModal.tsx +235 -149
- 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
|
|
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
|
-
<
|
|
421
|
-
|
|
422
|
-
|
|
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
|
+
}
|