@startsimpli/ui 0.4.5 → 0.4.7
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/package.json +2 -1
- package/src/components/ActivityTimeline.tsx +173 -0
- package/src/components/LogActivityDialog.tsx +303 -0
- package/src/components/QuickLogButtons.tsx +32 -0
- package/src/components/badge/StageBadge.tsx +31 -0
- package/src/components/badge/index.ts +3 -0
- package/src/components/command-palette/CommandPalette.tsx +344 -0
- package/src/components/command-palette/command-palette-context.tsx +51 -0
- package/src/components/command-palette/index.ts +3 -0
- package/src/components/compose/compose-header.tsx +72 -0
- package/src/components/compose/compose-loading.tsx +13 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/save-status-indicator.tsx +57 -0
- package/src/components/compose/send-confirmation-dialog.tsx +87 -0
- package/src/components/compose/subject-input.tsx +25 -0
- package/src/components/compose/useAutoSave.ts +93 -0
- package/src/components/dashboard/DashboardGrid.tsx +32 -0
- package/src/components/dashboard/DashboardSection.tsx +32 -0
- package/src/components/dashboard/MetricCard.tsx +129 -0
- package/src/components/dashboard/PeriodSelector.tsx +55 -0
- package/src/components/dashboard/SparklineTrend.tsx +102 -0
- package/src/components/dashboard/index.ts +14 -0
- package/src/components/email-dialogs/index.ts +14 -0
- package/src/components/email-dialogs/merge-fields.tsx +196 -0
- package/src/components/email-dialogs/preview-dialog.tsx +194 -0
- package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
- package/src/components/email-dialogs/template-picker.tsx +225 -0
- package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
- package/src/components/email-editor/add-block-menu.tsx +151 -0
- package/src/components/email-editor/block-toolbar.tsx +73 -0
- package/src/components/email-editor/blocks/button-block.tsx +44 -0
- package/src/components/email-editor/blocks/divider-block.tsx +43 -0
- package/src/components/email-editor/blocks/footer-block.tsx +39 -0
- package/src/components/email-editor/blocks/header-block.tsx +39 -0
- package/src/components/email-editor/blocks/image-block.tsx +61 -0
- package/src/components/email-editor/blocks/index.ts +9 -0
- package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
- package/src/components/email-editor/blocks/social-block.tsx +75 -0
- package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
- package/src/components/email-editor/blocks/text-block.tsx +75 -0
- package/src/components/email-editor/editor-sidebar.tsx +791 -0
- package/src/components/email-editor/email-editor.tsx +886 -0
- package/src/components/email-editor/index.ts +50 -0
- package/src/components/email-editor/renderer/block-renderers.ts +209 -0
- package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
- package/src/components/email-editor/types.ts +413 -0
- package/src/components/email-editor/utils/defaults.ts +116 -0
- package/src/components/email-editor/utils/undo-redo.ts +59 -0
- package/src/components/enrichment/EnrichButton.tsx +33 -0
- package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
- package/src/components/enrichment/QualityBadge.tsx +43 -0
- package/src/components/enrichment/index.ts +8 -0
- package/src/components/gantt/GanttChart.tsx +25 -25
- package/src/components/gantt/types.ts +5 -5
- package/src/components/index.ts +46 -0
- package/src/components/integrations/ConnectionStatus.tsx +77 -0
- package/src/components/integrations/IntegrationCard.tsx +92 -0
- package/src/components/integrations/index.ts +5 -0
- package/src/components/kanban/KanbanBoard.tsx +103 -0
- package/src/components/kanban/index.ts +2 -0
- package/src/components/lists/CreateListDialog.tsx +158 -0
- package/src/components/lists/ListCard.tsx +77 -0
- package/src/components/lists/index.ts +5 -0
- package/src/components/pipeline/StageTransitionModal.tsx +146 -0
- package/src/components/pipeline/index.ts +2 -0
- package/src/components/settings/SettingsCard.tsx +33 -0
- package/src/components/settings/SettingsLayout.tsx +28 -0
- package/src/components/settings/SettingsNav.tsx +42 -0
- package/src/components/settings/index.ts +6 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useMemo, useCallback } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
} from '../ui/dialog'
|
|
11
|
+
import { Button } from '../ui/button'
|
|
12
|
+
import { Badge } from '../ui/badge'
|
|
13
|
+
import { ScrollArea } from '../ui/scroll-area'
|
|
14
|
+
import { Input } from '../ui/input'
|
|
15
|
+
import { FileText, Loader2, Search } from 'lucide-react'
|
|
16
|
+
|
|
17
|
+
export interface EmailTemplate {
|
|
18
|
+
id: string
|
|
19
|
+
name: string
|
|
20
|
+
description: string | null
|
|
21
|
+
subject: string
|
|
22
|
+
blocks: string | null
|
|
23
|
+
category: string | null
|
|
24
|
+
isDefault: boolean
|
|
25
|
+
useCount: number
|
|
26
|
+
/** Alternative field name for subject */
|
|
27
|
+
subjectTemplate?: string
|
|
28
|
+
/** Alternative field name for body */
|
|
29
|
+
bodyTemplate?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface TemplatePickerProps {
|
|
33
|
+
open: boolean
|
|
34
|
+
onOpenChange: (open: boolean) => void
|
|
35
|
+
onSelectTemplate: (template: EmailTemplate) => void
|
|
36
|
+
/** Fetch templates from the API. Should return an array of templates. */
|
|
37
|
+
onLoadTemplates: () => Promise<EmailTemplate[]>
|
|
38
|
+
/** Optional callback to seed/create default templates when none exist */
|
|
39
|
+
onSeedTemplates?: () => Promise<void>
|
|
40
|
+
/** Optional category filter -- when set, only templates matching this category are shown */
|
|
41
|
+
categoryFilter?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Normalize API response to ensure subject/blocks fields are populated
|
|
45
|
+
function normalizeTemplates(raw: EmailTemplate[]): EmailTemplate[] {
|
|
46
|
+
return raw.map((t) => ({
|
|
47
|
+
...t,
|
|
48
|
+
subject: t.subject || t.subjectTemplate || '',
|
|
49
|
+
blocks: t.blocks || t.bodyTemplate || null,
|
|
50
|
+
}))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function TemplatePicker({
|
|
54
|
+
open,
|
|
55
|
+
onOpenChange,
|
|
56
|
+
onSelectTemplate,
|
|
57
|
+
onLoadTemplates,
|
|
58
|
+
onSeedTemplates,
|
|
59
|
+
categoryFilter,
|
|
60
|
+
}: TemplatePickerProps) {
|
|
61
|
+
const [templates, setTemplates] = useState<EmailTemplate[]>([])
|
|
62
|
+
const [loading, setLoading] = useState(true)
|
|
63
|
+
const [error, setError] = useState<string | null>(null)
|
|
64
|
+
const [search, setSearch] = useState('')
|
|
65
|
+
|
|
66
|
+
const fetchTemplates = useCallback(async () => {
|
|
67
|
+
try {
|
|
68
|
+
setLoading(true)
|
|
69
|
+
setError(null)
|
|
70
|
+
|
|
71
|
+
const data = await onLoadTemplates()
|
|
72
|
+
|
|
73
|
+
// If no templates and a seed function is provided, try seeding
|
|
74
|
+
if ((!data || data.length === 0) && onSeedTemplates) {
|
|
75
|
+
await onSeedTemplates()
|
|
76
|
+
const retryData = await onLoadTemplates()
|
|
77
|
+
setTemplates(normalizeTemplates(retryData || []))
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setTemplates(normalizeTemplates(data || []))
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error('Error fetching templates:', err)
|
|
84
|
+
setError('Failed to load templates')
|
|
85
|
+
} finally {
|
|
86
|
+
setLoading(false)
|
|
87
|
+
}
|
|
88
|
+
}, [onLoadTemplates, onSeedTemplates])
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (open) {
|
|
92
|
+
fetchTemplates()
|
|
93
|
+
setSearch('')
|
|
94
|
+
}
|
|
95
|
+
}, [open, fetchTemplates])
|
|
96
|
+
|
|
97
|
+
const filteredTemplates = useMemo(() => {
|
|
98
|
+
let result = templates
|
|
99
|
+
if (categoryFilter) {
|
|
100
|
+
result = result.filter((t) => t.category === categoryFilter)
|
|
101
|
+
}
|
|
102
|
+
if (search.trim()) {
|
|
103
|
+
const q = search.toLowerCase()
|
|
104
|
+
result = result.filter(
|
|
105
|
+
(t) =>
|
|
106
|
+
t.name.toLowerCase().includes(q) ||
|
|
107
|
+
(t.description && t.description.toLowerCase().includes(q)) ||
|
|
108
|
+
t.subject.toLowerCase().includes(q)
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
return result
|
|
112
|
+
}, [templates, categoryFilter, search])
|
|
113
|
+
|
|
114
|
+
const handleSelect = useCallback((template: EmailTemplate) => {
|
|
115
|
+
onSelectTemplate(template)
|
|
116
|
+
onOpenChange(false)
|
|
117
|
+
}, [onSelectTemplate, onOpenChange])
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
121
|
+
<DialogContent className="sm:max-w-[600px]">
|
|
122
|
+
<DialogHeader>
|
|
123
|
+
<DialogTitle>Choose a Template</DialogTitle>
|
|
124
|
+
<DialogDescription>
|
|
125
|
+
Start with a pre-built template to save time
|
|
126
|
+
</DialogDescription>
|
|
127
|
+
</DialogHeader>
|
|
128
|
+
|
|
129
|
+
{loading ? (
|
|
130
|
+
<div className="flex items-center justify-center py-8" role="status" aria-label="Loading templates">
|
|
131
|
+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" aria-hidden="true" />
|
|
132
|
+
<span className="ml-2 text-muted-foreground">Loading templates...</span>
|
|
133
|
+
</div>
|
|
134
|
+
) : error ? (
|
|
135
|
+
<div className="text-center py-8">
|
|
136
|
+
<p className="text-red-600">{error}</p>
|
|
137
|
+
<Button onClick={fetchTemplates} variant="outline" className="mt-2">
|
|
138
|
+
Retry
|
|
139
|
+
</Button>
|
|
140
|
+
</div>
|
|
141
|
+
) : templates.length === 0 ? (
|
|
142
|
+
<div className="text-center py-8">
|
|
143
|
+
<FileText className="h-12 w-12 mx-auto text-muted-foreground mb-2" />
|
|
144
|
+
<p className="text-muted-foreground">No templates available</p>
|
|
145
|
+
{onSeedTemplates && (
|
|
146
|
+
<Button
|
|
147
|
+
onClick={async () => {
|
|
148
|
+
await onSeedTemplates()
|
|
149
|
+
fetchTemplates()
|
|
150
|
+
}}
|
|
151
|
+
variant="outline"
|
|
152
|
+
className="mt-2"
|
|
153
|
+
>
|
|
154
|
+
Create Default Templates
|
|
155
|
+
</Button>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
) : (
|
|
159
|
+
<>
|
|
160
|
+
{/* Search */}
|
|
161
|
+
<div className="relative">
|
|
162
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
163
|
+
<Input
|
|
164
|
+
placeholder="Search templates..."
|
|
165
|
+
value={search}
|
|
166
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
167
|
+
className="pl-9"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<ScrollArea className="h-[350px] pr-4" role="region" aria-label="Template list">
|
|
172
|
+
<div className="space-y-3" role="listbox" aria-label="Available templates">
|
|
173
|
+
{filteredTemplates.map((template) => (
|
|
174
|
+
<button
|
|
175
|
+
key={template.id}
|
|
176
|
+
onClick={() => handleSelect(template)}
|
|
177
|
+
className="w-full text-left p-4 rounded-lg border hover:border-primary hover:bg-accent/50 transition-colors"
|
|
178
|
+
role="option"
|
|
179
|
+
aria-label={`Select ${template.name} template`}
|
|
180
|
+
>
|
|
181
|
+
<div className="flex items-start justify-between">
|
|
182
|
+
<div className="flex items-center gap-2">
|
|
183
|
+
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
184
|
+
<span className="font-medium">{template.name}</span>
|
|
185
|
+
</div>
|
|
186
|
+
<div className="flex items-center gap-2">
|
|
187
|
+
{template.isDefault && (
|
|
188
|
+
<Badge variant="secondary" className="text-xs">
|
|
189
|
+
Default
|
|
190
|
+
</Badge>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
{template.description && (
|
|
195
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
196
|
+
{template.description}
|
|
197
|
+
</p>
|
|
198
|
+
)}
|
|
199
|
+
<p className="text-sm text-muted-foreground mt-2 font-mono">
|
|
200
|
+
Subject: {template.subject}
|
|
201
|
+
</p>
|
|
202
|
+
</button>
|
|
203
|
+
))}
|
|
204
|
+
{filteredTemplates.length === 0 && (
|
|
205
|
+
<p className="text-center py-6 text-muted-foreground">
|
|
206
|
+
No templates match your search
|
|
207
|
+
</p>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
</ScrollArea>
|
|
211
|
+
|
|
212
|
+
<div className="flex justify-between items-center pt-4 border-t">
|
|
213
|
+
<p className="text-xs text-muted-foreground">
|
|
214
|
+
{filteredTemplates.length} template{filteredTemplates.length !== 1 ? 's' : ''} available
|
|
215
|
+
</p>
|
|
216
|
+
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
|
217
|
+
Cancel
|
|
218
|
+
</Button>
|
|
219
|
+
</div>
|
|
220
|
+
</>
|
|
221
|
+
)}
|
|
222
|
+
</DialogContent>
|
|
223
|
+
</Dialog>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from '../ui/dialog'
|
|
12
|
+
import { Button } from '../ui/button'
|
|
13
|
+
import { Input } from '../ui/input'
|
|
14
|
+
import { Label } from '../ui/label'
|
|
15
|
+
import { Checkbox } from '../ui/checkbox'
|
|
16
|
+
import {
|
|
17
|
+
Select,
|
|
18
|
+
SelectContent,
|
|
19
|
+
SelectItem,
|
|
20
|
+
SelectTrigger,
|
|
21
|
+
SelectValue,
|
|
22
|
+
} from '../ui/select'
|
|
23
|
+
import { Loader2, Send, AlertCircle } from 'lucide-react'
|
|
24
|
+
import { useToast } from '../toast'
|
|
25
|
+
|
|
26
|
+
interface TestSendRecipient {
|
|
27
|
+
id: string
|
|
28
|
+
name: string
|
|
29
|
+
email?: string | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface TestSendPayload {
|
|
33
|
+
testEmail: string
|
|
34
|
+
selectedRecipientId: string | undefined
|
|
35
|
+
includeTracking: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TestSendDialogProps {
|
|
39
|
+
open: boolean
|
|
40
|
+
onOpenChange: (open: boolean) => void
|
|
41
|
+
subject: string
|
|
42
|
+
/** Sample recipients whose data can be used for merge field substitution */
|
|
43
|
+
recipients?: TestSendRecipient[]
|
|
44
|
+
/** Default sample option label shown when no specific recipient is chosen */
|
|
45
|
+
defaultSampleLabel?: string
|
|
46
|
+
/** Callback to send the test email. Receives the test send configuration. */
|
|
47
|
+
onSendTest: (payload: TestSendPayload) => Promise<void>
|
|
48
|
+
/** Merge field help text */
|
|
49
|
+
mergeFieldHint?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function TestSendDialog({
|
|
53
|
+
open,
|
|
54
|
+
onOpenChange,
|
|
55
|
+
subject,
|
|
56
|
+
recipients = [],
|
|
57
|
+
defaultSampleLabel = 'Sample Data (John Smith)',
|
|
58
|
+
onSendTest,
|
|
59
|
+
mergeFieldHint = 'Merge fields like {{recipient.firstName}} will use this recipient\'s data',
|
|
60
|
+
}: TestSendDialogProps) {
|
|
61
|
+
const { toast } = useToast()
|
|
62
|
+
const [sending, setSending] = useState(false)
|
|
63
|
+
const [testEmail, setTestEmail] = useState('')
|
|
64
|
+
const [selectedRecipientId, setSelectedRecipientId] = useState<string>('sample')
|
|
65
|
+
const [includeTracking, setIncludeTracking] = useState(false)
|
|
66
|
+
|
|
67
|
+
const handleSendTest = async () => {
|
|
68
|
+
if (!testEmail) {
|
|
69
|
+
toast({ description: 'Please enter a test email address', variant: 'destructive' })
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setSending(true)
|
|
74
|
+
try {
|
|
75
|
+
await onSendTest({
|
|
76
|
+
testEmail,
|
|
77
|
+
selectedRecipientId: selectedRecipientId !== 'sample' ? selectedRecipientId : undefined,
|
|
78
|
+
includeTracking,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
toast({
|
|
82
|
+
description: `Test email sent to ${testEmail}`,
|
|
83
|
+
})
|
|
84
|
+
onOpenChange(false)
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error('Error sending test:', error)
|
|
87
|
+
toast({
|
|
88
|
+
description: error instanceof Error ? error.message : 'Failed to send test email',
|
|
89
|
+
variant: 'destructive'
|
|
90
|
+
})
|
|
91
|
+
} finally {
|
|
92
|
+
setSending(false)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
98
|
+
<DialogContent>
|
|
99
|
+
<DialogHeader>
|
|
100
|
+
<DialogTitle>Send Test Email</DialogTitle>
|
|
101
|
+
<DialogDescription>
|
|
102
|
+
Send a test version to yourself to preview how it will look in your inbox.
|
|
103
|
+
</DialogDescription>
|
|
104
|
+
</DialogHeader>
|
|
105
|
+
|
|
106
|
+
<div className="space-y-4 py-4">
|
|
107
|
+
{/* Subject preview */}
|
|
108
|
+
<div className="p-3 bg-muted rounded-lg">
|
|
109
|
+
<Label className="text-xs text-muted-foreground">Subject</Label>
|
|
110
|
+
<p className="text-sm font-medium mt-1">[TEST] {subject || '(no subject)'}</p>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* Test email input */}
|
|
114
|
+
<div className="space-y-2">
|
|
115
|
+
<Label htmlFor="test-email">Send to</Label>
|
|
116
|
+
<Input
|
|
117
|
+
id="test-email"
|
|
118
|
+
type="email"
|
|
119
|
+
value={testEmail}
|
|
120
|
+
onChange={(e) => setTestEmail(e.target.value)}
|
|
121
|
+
placeholder="your-email@example.com"
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
{/* Sample recipient data for merge fields */}
|
|
126
|
+
<div className="space-y-2">
|
|
127
|
+
<Label>Use data from</Label>
|
|
128
|
+
<Select value={selectedRecipientId} onValueChange={setSelectedRecipientId}>
|
|
129
|
+
<SelectTrigger>
|
|
130
|
+
<SelectValue placeholder="Select recipient for merge fields" />
|
|
131
|
+
</SelectTrigger>
|
|
132
|
+
<SelectContent>
|
|
133
|
+
<SelectItem value="sample">{defaultSampleLabel}</SelectItem>
|
|
134
|
+
{recipients.map(r => (
|
|
135
|
+
<SelectItem key={r.id} value={r.id}>
|
|
136
|
+
{r.name} {r.email ? `(${r.email})` : ''}
|
|
137
|
+
</SelectItem>
|
|
138
|
+
))}
|
|
139
|
+
</SelectContent>
|
|
140
|
+
</Select>
|
|
141
|
+
<p className="text-xs text-muted-foreground">
|
|
142
|
+
{mergeFieldHint}
|
|
143
|
+
</p>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{/* Tracking option */}
|
|
147
|
+
<div className="flex items-center space-x-2">
|
|
148
|
+
<Checkbox
|
|
149
|
+
id="include-tracking"
|
|
150
|
+
checked={includeTracking}
|
|
151
|
+
onCheckedChange={(checked) => setIncludeTracking(checked as boolean)}
|
|
152
|
+
/>
|
|
153
|
+
<Label htmlFor="include-tracking" className="text-sm font-normal">
|
|
154
|
+
Include tracking pixel and link tracking
|
|
155
|
+
</Label>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{/* Warning */}
|
|
159
|
+
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
|
|
160
|
+
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
|
161
|
+
<p className="text-sm">
|
|
162
|
+
Test emails are marked with [TEST] in the subject and include a banner indicating it's a test.
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<DialogFooter>
|
|
168
|
+
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>
|
|
169
|
+
Cancel
|
|
170
|
+
</Button>
|
|
171
|
+
<Button onClick={handleSendTest} disabled={sending || !testEmail}>
|
|
172
|
+
{sending ? (
|
|
173
|
+
<>
|
|
174
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
175
|
+
Sending...
|
|
176
|
+
</>
|
|
177
|
+
) : (
|
|
178
|
+
<>
|
|
179
|
+
<Send className="mr-2 h-4 w-4" />
|
|
180
|
+
Send Test
|
|
181
|
+
</>
|
|
182
|
+
)}
|
|
183
|
+
</Button>
|
|
184
|
+
</DialogFooter>
|
|
185
|
+
</DialogContent>
|
|
186
|
+
</Dialog>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { BlockType } from './types'
|
|
5
|
+
import { Button } from '../ui/button'
|
|
6
|
+
import {
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuContent,
|
|
9
|
+
DropdownMenuItem,
|
|
10
|
+
DropdownMenuLabel,
|
|
11
|
+
DropdownMenuSeparator,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
} from '../ui/dropdown-menu'
|
|
14
|
+
import {
|
|
15
|
+
Plus,
|
|
16
|
+
Type,
|
|
17
|
+
BarChart3,
|
|
18
|
+
Minus,
|
|
19
|
+
MousePointerClick,
|
|
20
|
+
ImageIcon,
|
|
21
|
+
ArrowUpDown,
|
|
22
|
+
Share2,
|
|
23
|
+
Building2,
|
|
24
|
+
FileText,
|
|
25
|
+
} from 'lucide-react'
|
|
26
|
+
|
|
27
|
+
interface BlockOption {
|
|
28
|
+
type: BlockType
|
|
29
|
+
label: string
|
|
30
|
+
icon: React.ReactNode
|
|
31
|
+
description: string
|
|
32
|
+
category: 'content' | 'layout' | 'preset'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const BLOCK_OPTIONS: BlockOption[] = [
|
|
36
|
+
// Content blocks
|
|
37
|
+
{ type: 'text', label: 'Text', icon: <Type className="h-4 w-4" />, description: 'Rich text content', category: 'content' },
|
|
38
|
+
{ type: 'image', label: 'Image', icon: <ImageIcon className="h-4 w-4" />, description: 'Image with caption', category: 'content' },
|
|
39
|
+
{ type: 'cta', label: 'Button', icon: <MousePointerClick className="h-4 w-4" />, description: 'Call to action button', category: 'content' },
|
|
40
|
+
{ type: 'metrics', label: 'Metrics', icon: <BarChart3 className="h-4 w-4" />, description: 'KPI grid', category: 'content' },
|
|
41
|
+
|
|
42
|
+
// Layout blocks
|
|
43
|
+
{ type: 'divider', label: 'Divider', icon: <Minus className="h-4 w-4" />, description: 'Visual separator', category: 'layout' },
|
|
44
|
+
{ type: 'spacer', label: 'Spacer', icon: <ArrowUpDown className="h-4 w-4" />, description: 'Vertical spacing', category: 'layout' },
|
|
45
|
+
|
|
46
|
+
// Preset blocks
|
|
47
|
+
{ type: 'header', label: 'Header', icon: <Building2 className="h-4 w-4" />, description: 'Logo + company name', category: 'preset' },
|
|
48
|
+
{ type: 'footer', label: 'Footer', icon: <FileText className="h-4 w-4" />, description: 'Unsubscribe + address', category: 'preset' },
|
|
49
|
+
{ type: 'social', label: 'Social Links', icon: <Share2 className="h-4 w-4" />, description: 'Social media icons', category: 'preset' },
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
interface AddBlockMenuProps {
|
|
53
|
+
onAdd: (type: BlockType) => void
|
|
54
|
+
variant?: 'default' | 'small' | 'inline'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function AddBlockMenu({ onAdd, variant = 'default' }: AddBlockMenuProps) {
|
|
58
|
+
const contentBlocks = BLOCK_OPTIONS.filter((b) => b.category === 'content')
|
|
59
|
+
const layoutBlocks = BLOCK_OPTIONS.filter((b) => b.category === 'layout')
|
|
60
|
+
const presetBlocks = BLOCK_OPTIONS.filter((b) => b.category === 'preset')
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<DropdownMenu>
|
|
64
|
+
<DropdownMenuTrigger asChild>
|
|
65
|
+
{variant === 'small' ? (
|
|
66
|
+
<Button
|
|
67
|
+
variant="outline"
|
|
68
|
+
size="icon"
|
|
69
|
+
className="h-6 w-6 rounded-full border-dashed"
|
|
70
|
+
>
|
|
71
|
+
<Plus className="h-3 w-3" />
|
|
72
|
+
</Button>
|
|
73
|
+
) : variant === 'inline' ? (
|
|
74
|
+
<Button
|
|
75
|
+
variant="ghost"
|
|
76
|
+
size="sm"
|
|
77
|
+
className="h-8 gap-1 text-muted-foreground hover:text-foreground"
|
|
78
|
+
>
|
|
79
|
+
<Plus className="h-3 w-3" />
|
|
80
|
+
<span className="text-xs">Add block</span>
|
|
81
|
+
</Button>
|
|
82
|
+
) : (
|
|
83
|
+
<Button variant="outline" size="sm" className="gap-2">
|
|
84
|
+
<Plus className="h-4 w-4" />
|
|
85
|
+
Add Block
|
|
86
|
+
</Button>
|
|
87
|
+
)}
|
|
88
|
+
</DropdownMenuTrigger>
|
|
89
|
+
<DropdownMenuContent align="center" className="w-56">
|
|
90
|
+
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
91
|
+
Content
|
|
92
|
+
</DropdownMenuLabel>
|
|
93
|
+
{contentBlocks.map((option) => (
|
|
94
|
+
<DropdownMenuItem
|
|
95
|
+
key={option.type}
|
|
96
|
+
onClick={() => onAdd(option.type)}
|
|
97
|
+
className="flex items-center gap-3"
|
|
98
|
+
>
|
|
99
|
+
{option.icon}
|
|
100
|
+
<div>
|
|
101
|
+
<div className="text-sm font-medium">{option.label}</div>
|
|
102
|
+
<div className="text-xs text-muted-foreground">
|
|
103
|
+
{option.description}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</DropdownMenuItem>
|
|
107
|
+
))}
|
|
108
|
+
|
|
109
|
+
<DropdownMenuSeparator />
|
|
110
|
+
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
111
|
+
Layout
|
|
112
|
+
</DropdownMenuLabel>
|
|
113
|
+
{layoutBlocks.map((option) => (
|
|
114
|
+
<DropdownMenuItem
|
|
115
|
+
key={option.type}
|
|
116
|
+
onClick={() => onAdd(option.type)}
|
|
117
|
+
className="flex items-center gap-3"
|
|
118
|
+
>
|
|
119
|
+
{option.icon}
|
|
120
|
+
<div>
|
|
121
|
+
<div className="text-sm font-medium">{option.label}</div>
|
|
122
|
+
<div className="text-xs text-muted-foreground">
|
|
123
|
+
{option.description}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</DropdownMenuItem>
|
|
127
|
+
))}
|
|
128
|
+
|
|
129
|
+
<DropdownMenuSeparator />
|
|
130
|
+
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
131
|
+
Presets
|
|
132
|
+
</DropdownMenuLabel>
|
|
133
|
+
{presetBlocks.map((option) => (
|
|
134
|
+
<DropdownMenuItem
|
|
135
|
+
key={option.type}
|
|
136
|
+
onClick={() => onAdd(option.type)}
|
|
137
|
+
className="flex items-center gap-3"
|
|
138
|
+
>
|
|
139
|
+
{option.icon}
|
|
140
|
+
<div>
|
|
141
|
+
<div className="text-sm font-medium">{option.label}</div>
|
|
142
|
+
<div className="text-xs text-muted-foreground">
|
|
143
|
+
{option.description}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</DropdownMenuItem>
|
|
147
|
+
))}
|
|
148
|
+
</DropdownMenuContent>
|
|
149
|
+
</DropdownMenu>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Button } from '../ui/button'
|
|
4
|
+
import {
|
|
5
|
+
Trash2,
|
|
6
|
+
Copy,
|
|
7
|
+
ChevronUp,
|
|
8
|
+
ChevronDown,
|
|
9
|
+
} from 'lucide-react'
|
|
10
|
+
|
|
11
|
+
interface BlockToolbarProps {
|
|
12
|
+
onDelete: () => void
|
|
13
|
+
onDuplicate: () => void
|
|
14
|
+
onMoveUp: () => void
|
|
15
|
+
onMoveDown: () => void
|
|
16
|
+
canMoveUp: boolean
|
|
17
|
+
canMoveDown: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function BlockToolbar({
|
|
21
|
+
onDelete,
|
|
22
|
+
onDuplicate,
|
|
23
|
+
onMoveUp,
|
|
24
|
+
onMoveDown,
|
|
25
|
+
canMoveUp,
|
|
26
|
+
canMoveDown,
|
|
27
|
+
}: BlockToolbarProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="absolute right-1 top-1 flex items-center gap-0.5 z-20">
|
|
30
|
+
<div className="bg-background border rounded-md shadow-sm p-0.5 flex items-center gap-0.5">
|
|
31
|
+
<Button
|
|
32
|
+
variant="ghost"
|
|
33
|
+
size="icon"
|
|
34
|
+
className="h-6 w-6"
|
|
35
|
+
onClick={onMoveUp}
|
|
36
|
+
disabled={!canMoveUp}
|
|
37
|
+
title="Move up"
|
|
38
|
+
>
|
|
39
|
+
<ChevronUp className="h-3.5 w-3.5" />
|
|
40
|
+
</Button>
|
|
41
|
+
<Button
|
|
42
|
+
variant="ghost"
|
|
43
|
+
size="icon"
|
|
44
|
+
className="h-6 w-6"
|
|
45
|
+
onClick={onMoveDown}
|
|
46
|
+
disabled={!canMoveDown}
|
|
47
|
+
title="Move down"
|
|
48
|
+
>
|
|
49
|
+
<ChevronDown className="h-3.5 w-3.5" />
|
|
50
|
+
</Button>
|
|
51
|
+
<div className="w-px h-4 bg-border" />
|
|
52
|
+
<Button
|
|
53
|
+
variant="ghost"
|
|
54
|
+
size="icon"
|
|
55
|
+
className="h-6 w-6"
|
|
56
|
+
onClick={onDuplicate}
|
|
57
|
+
title="Duplicate"
|
|
58
|
+
>
|
|
59
|
+
<Copy className="h-3.5 w-3.5" />
|
|
60
|
+
</Button>
|
|
61
|
+
<Button
|
|
62
|
+
variant="ghost"
|
|
63
|
+
size="icon"
|
|
64
|
+
className="h-6 w-6 text-destructive hover:text-destructive"
|
|
65
|
+
onClick={onDelete}
|
|
66
|
+
title="Delete"
|
|
67
|
+
>
|
|
68
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
69
|
+
</Button>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { CTABlock as CTABlockType } from '../types'
|
|
5
|
+
|
|
6
|
+
interface ButtonBlockProps {
|
|
7
|
+
block: CTABlockType
|
|
8
|
+
onChange: (block: CTABlockType) => void
|
|
9
|
+
isEditing?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ButtonBlockEditor({ block, onChange, isEditing = true }: ButtonBlockProps) {
|
|
13
|
+
const buttonColor = block.buttonColor || '#2563eb'
|
|
14
|
+
const textColor = block.textColor || '#ffffff'
|
|
15
|
+
const borderRadius = block.borderRadius ?? 6
|
|
16
|
+
const paddingH = block.paddingH ?? 24
|
|
17
|
+
const paddingV = block.paddingV ?? 12
|
|
18
|
+
|
|
19
|
+
const alignClass =
|
|
20
|
+
block.alignment === 'left'
|
|
21
|
+
? 'text-left'
|
|
22
|
+
: block.alignment === 'right'
|
|
23
|
+
? 'text-right'
|
|
24
|
+
: 'text-center'
|
|
25
|
+
|
|
26
|
+
const buttonStyle: React.CSSProperties = {
|
|
27
|
+
display: 'inline-block',
|
|
28
|
+
padding: `${paddingV}px ${paddingH}px`,
|
|
29
|
+
backgroundColor: buttonColor,
|
|
30
|
+
color: textColor,
|
|
31
|
+
borderRadius: `${borderRadius}px`,
|
|
32
|
+
textDecoration: 'none',
|
|
33
|
+
fontWeight: 500,
|
|
34
|
+
fontSize: 14,
|
|
35
|
+
cursor: isEditing ? 'default' : 'pointer',
|
|
36
|
+
border: 'none',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className={`py-4 ${alignClass}`}>
|
|
41
|
+
<span style={buttonStyle}>{block.text || 'Button'}</span>
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|