@jhits/plugin-newsletter 0.0.10 → 0.0.11

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 (57) hide show
  1. package/package.json +3 -2
  2. package/src/api/email-utils.ts +165 -0
  3. package/src/api/handler.ts +28 -0
  4. package/src/api/handlers/index.ts +44 -0
  5. package/src/api/handlers/newsletters.ts +332 -0
  6. package/src/api/handlers/send-newsletter.ts +288 -0
  7. package/src/api/handlers/settings.ts +403 -0
  8. package/src/api/handlers/subscribers.ts +152 -0
  9. package/src/api/handlers/upload.ts +47 -0
  10. package/src/api/handlers/welcome-email.ts +210 -0
  11. package/src/api/router.ts +166 -0
  12. package/src/index.server.ts +12 -0
  13. package/src/index.tsx +353 -0
  14. package/src/index.tsx.patch +98 -0
  15. package/src/init.tsx +72 -0
  16. package/src/lib/blocks/BlockRenderer.tsx +125 -0
  17. package/src/lib/email/EmailRenderer.tsx +420 -0
  18. package/src/lib/email/index.ts +6 -0
  19. package/src/lib/i18n.ts +82 -0
  20. package/src/lib/mappers/apiMapper.ts +57 -0
  21. package/src/lib/utils/blockHelpers.ts +71 -0
  22. package/src/lib/utils/slugify.ts +43 -0
  23. package/src/registry/BlockRegistry.ts +53 -0
  24. package/src/registry/index.ts +5 -0
  25. package/src/state/EditorContext.tsx +278 -0
  26. package/src/state/index.ts +10 -0
  27. package/src/state/reducer.ts +561 -0
  28. package/src/state/types.ts +154 -0
  29. package/src/types/block.ts +275 -0
  30. package/src/types/newsletter.ts +152 -0
  31. package/src/types/registry.ts +14 -0
  32. package/src/views/CanvasEditor/BlockWrapper.tsx +143 -0
  33. package/src/views/CanvasEditor/CanvasEditorView.tsx +343 -0
  34. package/src/views/CanvasEditor/EditorBody.tsx +95 -0
  35. package/src/views/CanvasEditor/EditorHeader.tsx +255 -0
  36. package/src/views/CanvasEditor/components/CustomBlockItem.tsx +83 -0
  37. package/src/views/CanvasEditor/components/EditorCanvas.tsx +674 -0
  38. package/src/views/CanvasEditor/components/EditorLibrary.tsx +120 -0
  39. package/src/views/CanvasEditor/components/EditorSidebar.tsx +139 -0
  40. package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
  41. package/src/views/CanvasEditor/components/LibraryItem.tsx +71 -0
  42. package/src/views/CanvasEditor/components/SlashCommandDetector.tsx +196 -0
  43. package/src/views/CanvasEditor/components/SlashCommandMenu.tsx +131 -0
  44. package/src/views/CanvasEditor/components/index.ts +16 -0
  45. package/src/views/CanvasEditor/hooks/index.ts +7 -0
  46. package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +136 -0
  47. package/src/views/CanvasEditor/hooks/useNewsletterLoader.ts +73 -0
  48. package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +54 -0
  49. package/src/views/CanvasEditor/hooks/useSlashCommand.ts +106 -0
  50. package/src/views/CanvasEditor/index.ts +12 -0
  51. package/src/views/NewsletterEditor.tsx +42 -0
  52. package/src/views/NewsletterManager.tsx +483 -0
  53. package/src/views/SettingsView.tsx +216 -0
  54. package/src/views/SubscribersView.tsx +269 -0
  55. package/src/views/components/SendNewsletterModal.tsx +322 -0
  56. package/src/views/components/SmtpSettingsModal.tsx +433 -0
  57. package/src/views/components/TestEmailModal.tsx +268 -0
@@ -0,0 +1,255 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef, useEffect } from 'react';
4
+ import { ArrowLeft, Settings2, Save, Edit, Eye, Plus } from 'lucide-react';
5
+ import { useEditor } from '../../state/EditorContext';
6
+
7
+ const ALL_LANGUAGES = [
8
+ { code: 'en', name: 'English' },
9
+ { code: 'nl', name: 'Dutch' },
10
+ { code: 'sv', name: 'Swedish' },
11
+ { code: 'de', name: 'German' },
12
+ { code: 'fr', name: 'French' },
13
+ { code: 'es', name: 'Spanish' },
14
+ { code: 'it', name: 'Italian' },
15
+ { code: 'pt', name: 'Portuguese' },
16
+ { code: 'pl', name: 'Polish' },
17
+ { code: 'ru', name: 'Russian' },
18
+ { code: 'ja', name: 'Japanese' },
19
+ { code: 'zh', name: 'Chinese' },
20
+ { code: 'ar', name: 'Arabic' },
21
+ { code: 'tr', name: 'Turkish' },
22
+ { code: 'cs', name: 'Czech' },
23
+ { code: 'da', name: 'Danish' },
24
+ { code: 'fi', name: 'Finnish' },
25
+ { code: 'el', name: 'Greek' },
26
+ { code: 'he', name: 'Hebrew' },
27
+ { code: 'hi', name: 'Hindi' },
28
+ { code: 'hu', name: 'Hungarian' },
29
+ { code: 'id', name: 'Indonesian' },
30
+ { code: 'ko', name: 'Korean' },
31
+ { code: 'no', name: 'Norwegian' },
32
+ { code: 'ro', name: 'Romanian' },
33
+ { code: 'th', name: 'Thai' },
34
+ { code: 'uk', name: 'Ukrainian' },
35
+ { code: 'vi', name: 'Vietnamese' },
36
+ ];
37
+
38
+ export interface EditorHeaderProps {
39
+ isPreviewMode: boolean;
40
+ onPreviewToggle: () => void;
41
+ isSidebarOpen: boolean;
42
+ onSidebarToggle: () => void;
43
+ isSaving: boolean;
44
+ onSave: () => Promise<void>;
45
+ onSaveError: (error: string | null) => void;
46
+ isDirty?: boolean;
47
+ isWelcomeEmail?: boolean;
48
+ languages?: string[];
49
+ currentLanguage?: string;
50
+ onLanguageChange?: (language: string) => void;
51
+ onAddLanguage?: (language: string) => void;
52
+ }
53
+
54
+ export function EditorHeader({
55
+ isPreviewMode,
56
+ onPreviewToggle,
57
+ isSidebarOpen,
58
+ onSidebarToggle,
59
+ isSaving,
60
+ onSave,
61
+ onSaveError,
62
+ isDirty = false,
63
+ isWelcomeEmail = false,
64
+ languages = ['en'],
65
+ currentLanguage = 'en',
66
+ onLanguageChange,
67
+ onAddLanguage,
68
+ }: EditorHeaderProps) {
69
+ const { state } = useEditor();
70
+ const [saveError, setSaveError] = useState<string | null>(null);
71
+ const [isAddLangOpen, setIsAddLangOpen] = useState(false);
72
+ const addLangRef = useRef<HTMLDivElement>(null);
73
+
74
+ useEffect(() => {
75
+ const handleClickOutside = (event: MouseEvent) => {
76
+ if (addLangRef.current && !addLangRef.current.contains(event.target as Node)) {
77
+ setIsAddLangOpen(false);
78
+ }
79
+ };
80
+ document.addEventListener('mousedown', handleClickOutside);
81
+ return () => document.removeEventListener('mousedown', handleClickOutside);
82
+ }, []);
83
+
84
+ const availableToAdd = ALL_LANGUAGES.filter(l => !languages.includes(l.code));
85
+
86
+ const handleSave = async () => {
87
+ try {
88
+ setSaveError(null);
89
+ await onSave();
90
+ } catch (error: any) {
91
+ console.error('[EditorHeader] Failed to save newsletter:', error);
92
+ let errorMessage = error.message || 'Failed to save newsletter';
93
+ if (errorMessage.includes('Unauthorized')) {
94
+ errorMessage = 'You are not authorized to save. Please log in again.';
95
+ }
96
+ setSaveError(errorMessage);
97
+ onSaveError(errorMessage);
98
+ }
99
+ };
100
+
101
+ const currentLangName = ALL_LANGUAGES.find(l => l.code === currentLanguage)?.name || currentLanguage.toUpperCase();
102
+
103
+ return (
104
+ <header className="flex items-center justify-between px-6 py-3 bg-dashboard-sidebar backdrop-blur-md border-b border-dashboard-border flex-none shrink-0 z-50 relative">
105
+ <div className="flex items-center gap-6">
106
+ <button
107
+ onClick={() => {
108
+ if (isDirty) {
109
+ const confirmed = window.confirm(
110
+ 'You have unsaved changes. Are you sure you want to leave? Your changes will be lost.'
111
+ );
112
+ if (!confirmed) {
113
+ return;
114
+ }
115
+ }
116
+ window.location.href = '/dashboard/newsletter';
117
+ }}
118
+ className="text-neutral-500 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white transition-colors"
119
+ >
120
+ <ArrowLeft size={20} strokeWidth={1.5} />
121
+ </button>
122
+
123
+ {/* Language Selector */}
124
+ {languages.length > 0 && (
125
+ <div className="flex items-center gap-2">
126
+ <select
127
+ value={currentLanguage}
128
+ onChange={(e) => {
129
+ if (onLanguageChange) {
130
+ onLanguageChange(e.target.value);
131
+ }
132
+ }}
133
+ className="px-3 py-1.5 text-xs bg-dashboard-bg border border-dashboard-border rounded-lg outline-none focus:border-primary transition-all text-dashboard-text font-medium"
134
+ >
135
+ {languages.map((lang) => {
136
+ const langInfo = ALL_LANGUAGES.find(l => l.code === lang);
137
+ return (
138
+ <option key={lang} value={lang}>
139
+ {langInfo?.name || lang.toUpperCase()}
140
+ </option>
141
+ );
142
+ })}
143
+ </select>
144
+
145
+ {/* Add Language Button */}
146
+ {availableToAdd.length > 0 && (
147
+ <div className="relative" ref={addLangRef}>
148
+ <button
149
+ onClick={() => setIsAddLangOpen(!isAddLangOpen)}
150
+ className="p-1.5 text-neutral-500 hover:text-primary transition-colors"
151
+ title="Add language"
152
+ >
153
+ <Plus size={16} />
154
+ </button>
155
+
156
+ {isAddLangOpen && (
157
+ <div className="absolute top-full left-0 mt-1 w-48 bg-dashboard-card border border-dashboard-border rounded-lg shadow-lg z-[100] py-1 max-h-60 overflow-y-auto">
158
+ <div className="px-3 py-2 text-[10px] uppercase tracking-wider text-neutral-500 font-bold border-b border-dashboard-border">
159
+ Add Language
160
+ </div>
161
+ {availableToAdd.map((lang) => (
162
+ <button
163
+ key={lang.code}
164
+ onClick={() => {
165
+ if (onAddLanguage) {
166
+ onAddLanguage(lang.code);
167
+ }
168
+ setIsAddLangOpen(false);
169
+ }}
170
+ className="w-full text-left px-3 py-2 text-xs hover:bg-dashboard-bg transition-colors text-dashboard-text"
171
+ >
172
+ {lang.name}
173
+ </button>
174
+ ))}
175
+ </div>
176
+ )}
177
+ </div>
178
+ )}
179
+ </div>
180
+ )}
181
+ </div>
182
+
183
+ <div className="flex items-center gap-4">
184
+ {/* Edit/Preview Toggle */}
185
+ <div className="flex items-center bg-dashboard-bg border border-dashboard-border rounded-full p-1 gap-1">
186
+ <button
187
+ onClick={() => {
188
+ if (isPreviewMode) {
189
+ onPreviewToggle();
190
+ }
191
+ }}
192
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] uppercase tracking-widest font-bold transition-all ${
193
+ !isPreviewMode
194
+ ? 'bg-primary text-white shadow-sm'
195
+ : 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white'
196
+ }`}
197
+ title="Edit mode"
198
+ >
199
+ <Edit size={12} strokeWidth={2.5} />
200
+ <span>Edit</span>
201
+ </button>
202
+ <button
203
+ onClick={() => {
204
+ if (!isPreviewMode) {
205
+ onPreviewToggle();
206
+ }
207
+ }}
208
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] uppercase tracking-widest font-bold transition-all ${
209
+ isPreviewMode
210
+ ? 'bg-primary text-white shadow-sm'
211
+ : 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white'
212
+ }`}
213
+ title="Preview mode"
214
+ >
215
+ <Eye size={12} strokeWidth={2.5} />
216
+ <span>Preview</span>
217
+ </button>
218
+ </div>
219
+
220
+ {/* Settings Toggle */}
221
+ <button
222
+ onClick={onSidebarToggle}
223
+ className={`flex items-center gap-2 text-[10px] uppercase tracking-widest font-black transition-all ${isSidebarOpen ? 'text-dashboard-text' : 'text-neutral-500 dark:text-neutral-400'
224
+ }`}
225
+ >
226
+ <Settings2 size={16} strokeWidth={1.5} />
227
+ Settings
228
+ </button>
229
+
230
+ {/* Save Button */}
231
+ <button
232
+ onClick={handleSave}
233
+ disabled={isSaving || !isDirty}
234
+ className={`inline-flex items-center gap-2 px-4 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-colors shadow-lg ${
235
+ isSaving || !isDirty
236
+ ? 'bg-neutral-400 text-white cursor-not-allowed'
237
+ : 'bg-primary text-white hover:bg-primary/90'
238
+ }`}
239
+ >
240
+ {isSaving ? (
241
+ <>
242
+ <div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />
243
+ Saving...
244
+ </>
245
+ ) : (
246
+ <>
247
+ <Save size={14} />
248
+ {isDirty ? 'Save Changes' : 'Saved'}
249
+ </>
250
+ )}
251
+ </button>
252
+ </div>
253
+ </header>
254
+ );
255
+ }
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef } from 'react';
4
+ import { GripVertical } from 'lucide-react';
5
+
6
+ export interface CustomBlockItemProps {
7
+ blockType: string;
8
+ name: string;
9
+ description?: string;
10
+ icon: React.ReactNode;
11
+ onAddBlock?: (blockType: string) => void;
12
+ }
13
+
14
+ export function CustomBlockItem({
15
+ blockType,
16
+ name,
17
+ description,
18
+ icon,
19
+ onAddBlock
20
+ }: CustomBlockItemProps) {
21
+ const [hasDragged, setHasDragged] = useState(false);
22
+ const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
23
+
24
+ const handleDragStart = (e: React.DragEvent) => {
25
+ e.dataTransfer.setData('block-type', blockType);
26
+ e.dataTransfer.effectAllowed = 'move';
27
+ setHasDragged(true);
28
+ };
29
+
30
+ const handleMouseDown = (e: React.MouseEvent) => {
31
+ mouseDownRef.current = { x: e.clientX, y: e.clientY };
32
+ setHasDragged(false);
33
+ };
34
+
35
+ const handleMouseMove = (e: React.MouseEvent) => {
36
+ if (mouseDownRef.current) {
37
+ const dx = Math.abs(e.clientX - mouseDownRef.current.x);
38
+ const dy = Math.abs(e.clientY - mouseDownRef.current.y);
39
+ if (dx > 5 || dy > 5) {
40
+ setHasDragged(true);
41
+ }
42
+ }
43
+ };
44
+
45
+ const handleClick = (e: React.MouseEvent) => {
46
+ if (!hasDragged && onAddBlock) {
47
+ e.preventDefault();
48
+ e.stopPropagation();
49
+ onAddBlock(blockType);
50
+ }
51
+ mouseDownRef.current = null;
52
+ setTimeout(() => setHasDragged(false), 100);
53
+ };
54
+
55
+ return (
56
+ <div
57
+ draggable
58
+ onDragStart={handleDragStart}
59
+ onMouseDown={handleMouseDown}
60
+ onMouseMove={handleMouseMove}
61
+ onClick={handleClick}
62
+ className="p-4 rounded-xl border border-dashboard-border bg-dashboard-bg hover:border-primary cursor-pointer transition-all group"
63
+ title={description}
64
+ >
65
+ <div className="flex items-center justify-between mb-2">
66
+ <div className="flex items-center gap-2">
67
+ <div className="text-neutral-500 dark:text-neutral-400 group-hover:text-primary dark:group-hover:text-primary transition-colors">
68
+ {icon}
69
+ </div>
70
+ <span className="text-[10px] font-bold uppercase tracking-wider text-neutral-700 dark:text-neutral-300 group-hover:text-neutral-950 dark:group-hover:text-white transition-colors">
71
+ {name}
72
+ </span>
73
+ </div>
74
+ <GripVertical size={12} className="text-neutral-400 dark:text-neutral-500 group-hover:text-neutral-600 dark:group-hover:text-neutral-400" />
75
+ </div>
76
+ {description && (
77
+ <p className="text-[9px] text-neutral-500 dark:text-neutral-400 leading-relaxed line-clamp-2">
78
+ {description}
79
+ </p>
80
+ )}
81
+ </div>
82
+ );
83
+ }