@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.
Files changed (69) hide show
  1. package/package.json +2 -1
  2. package/src/components/ActivityTimeline.tsx +173 -0
  3. package/src/components/LogActivityDialog.tsx +303 -0
  4. package/src/components/QuickLogButtons.tsx +32 -0
  5. package/src/components/badge/StageBadge.tsx +31 -0
  6. package/src/components/badge/index.ts +3 -0
  7. package/src/components/command-palette/CommandPalette.tsx +344 -0
  8. package/src/components/command-palette/command-palette-context.tsx +51 -0
  9. package/src/components/command-palette/index.ts +3 -0
  10. package/src/components/compose/compose-header.tsx +72 -0
  11. package/src/components/compose/compose-loading.tsx +13 -0
  12. package/src/components/compose/index.ts +6 -0
  13. package/src/components/compose/save-status-indicator.tsx +57 -0
  14. package/src/components/compose/send-confirmation-dialog.tsx +87 -0
  15. package/src/components/compose/subject-input.tsx +25 -0
  16. package/src/components/compose/useAutoSave.ts +93 -0
  17. package/src/components/dashboard/DashboardGrid.tsx +32 -0
  18. package/src/components/dashboard/DashboardSection.tsx +32 -0
  19. package/src/components/dashboard/MetricCard.tsx +129 -0
  20. package/src/components/dashboard/PeriodSelector.tsx +55 -0
  21. package/src/components/dashboard/SparklineTrend.tsx +102 -0
  22. package/src/components/dashboard/index.ts +14 -0
  23. package/src/components/email-dialogs/index.ts +14 -0
  24. package/src/components/email-dialogs/merge-fields.tsx +196 -0
  25. package/src/components/email-dialogs/preview-dialog.tsx +194 -0
  26. package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
  27. package/src/components/email-dialogs/template-picker.tsx +225 -0
  28. package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
  29. package/src/components/email-editor/add-block-menu.tsx +151 -0
  30. package/src/components/email-editor/block-toolbar.tsx +73 -0
  31. package/src/components/email-editor/blocks/button-block.tsx +44 -0
  32. package/src/components/email-editor/blocks/divider-block.tsx +43 -0
  33. package/src/components/email-editor/blocks/footer-block.tsx +39 -0
  34. package/src/components/email-editor/blocks/header-block.tsx +39 -0
  35. package/src/components/email-editor/blocks/image-block.tsx +61 -0
  36. package/src/components/email-editor/blocks/index.ts +9 -0
  37. package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
  38. package/src/components/email-editor/blocks/social-block.tsx +75 -0
  39. package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
  40. package/src/components/email-editor/blocks/text-block.tsx +75 -0
  41. package/src/components/email-editor/editor-sidebar.tsx +791 -0
  42. package/src/components/email-editor/email-editor.tsx +886 -0
  43. package/src/components/email-editor/index.ts +50 -0
  44. package/src/components/email-editor/renderer/block-renderers.ts +209 -0
  45. package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
  46. package/src/components/email-editor/types.ts +413 -0
  47. package/src/components/email-editor/utils/defaults.ts +116 -0
  48. package/src/components/email-editor/utils/undo-redo.ts +59 -0
  49. package/src/components/enrichment/EnrichButton.tsx +33 -0
  50. package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
  51. package/src/components/enrichment/QualityBadge.tsx +43 -0
  52. package/src/components/enrichment/index.ts +8 -0
  53. package/src/components/gantt/GanttChart.tsx +25 -25
  54. package/src/components/gantt/types.ts +5 -5
  55. package/src/components/index.ts +46 -0
  56. package/src/components/integrations/ConnectionStatus.tsx +77 -0
  57. package/src/components/integrations/IntegrationCard.tsx +92 -0
  58. package/src/components/integrations/index.ts +5 -0
  59. package/src/components/kanban/KanbanBoard.tsx +103 -0
  60. package/src/components/kanban/index.ts +2 -0
  61. package/src/components/lists/CreateListDialog.tsx +158 -0
  62. package/src/components/lists/ListCard.tsx +77 -0
  63. package/src/components/lists/index.ts +5 -0
  64. package/src/components/pipeline/StageTransitionModal.tsx +146 -0
  65. package/src/components/pipeline/index.ts +2 -0
  66. package/src/components/settings/SettingsCard.tsx +33 -0
  67. package/src/components/settings/SettingsLayout.tsx +28 -0
  68. package/src/components/settings/SettingsNav.tsx +42 -0
  69. 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&apos;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
+ }