@startsimpli/ui 0.4.6 → 0.4.8

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 (122) hide show
  1. package/package.json +2 -1
  2. package/src/__mocks__/next/link.js +11 -0
  3. package/src/components/ActivityTimeline.tsx +173 -0
  4. package/src/components/LogActivityDialog.tsx +303 -0
  5. package/src/components/QuickLogButtons.tsx +32 -0
  6. package/src/components/account/__tests__/account.test.tsx +315 -0
  7. package/src/components/badge/StageBadge.tsx +31 -0
  8. package/src/components/badge/index.ts +3 -0
  9. package/src/components/command-palette/CommandGroup.tsx +23 -0
  10. package/src/components/command-palette/CommandPalette.tsx +327 -0
  11. package/src/components/command-palette/CommandResultItem.tsx +59 -0
  12. package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
  13. package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
  14. package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
  15. package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
  16. package/src/components/command-palette/command-palette-context.tsx +51 -0
  17. package/src/components/command-palette/index.ts +9 -0
  18. package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
  19. package/src/components/compose/__tests__/compose.test.tsx +656 -0
  20. package/src/components/compose/compose-header.tsx +72 -0
  21. package/src/components/compose/compose-loading.tsx +13 -0
  22. package/src/components/compose/index.ts +6 -0
  23. package/src/components/compose/save-status-indicator.tsx +57 -0
  24. package/src/components/compose/send-confirmation-dialog.tsx +87 -0
  25. package/src/components/compose/subject-input.tsx +25 -0
  26. package/src/components/compose/useAutoSave.ts +93 -0
  27. package/src/components/dashboard/DashboardGrid.tsx +32 -0
  28. package/src/components/dashboard/DashboardSection.tsx +32 -0
  29. package/src/components/dashboard/MetricCard.tsx +129 -0
  30. package/src/components/dashboard/PeriodSelector.tsx +55 -0
  31. package/src/components/dashboard/PipelineFunnel.tsx +126 -0
  32. package/src/components/dashboard/SparklineTrend.tsx +102 -0
  33. package/src/components/dashboard/TopCampaigns.tsx +132 -0
  34. package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
  35. package/src/components/dashboard/index.ts +20 -0
  36. package/src/components/dialog/ConfirmDialog.tsx +72 -0
  37. package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
  38. package/src/components/dialog/index.ts +3 -0
  39. package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
  40. package/src/components/email-dialogs/index.ts +14 -0
  41. package/src/components/email-dialogs/merge-fields.tsx +196 -0
  42. package/src/components/email-dialogs/preview-dialog.tsx +194 -0
  43. package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
  44. package/src/components/email-dialogs/template-picker.tsx +225 -0
  45. package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
  46. package/src/components/email-editor/BlockRenderer.tsx +120 -0
  47. package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
  48. package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
  49. package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
  50. package/src/components/email-editor/add-block-menu.tsx +151 -0
  51. package/src/components/email-editor/block-toolbar.tsx +73 -0
  52. package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
  53. package/src/components/email-editor/blocks/button-block.tsx +44 -0
  54. package/src/components/email-editor/blocks/divider-block.tsx +43 -0
  55. package/src/components/email-editor/blocks/footer-block.tsx +39 -0
  56. package/src/components/email-editor/blocks/header-block.tsx +39 -0
  57. package/src/components/email-editor/blocks/image-block.tsx +61 -0
  58. package/src/components/email-editor/blocks/index.ts +9 -0
  59. package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
  60. package/src/components/email-editor/blocks/social-block.tsx +75 -0
  61. package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
  62. package/src/components/email-editor/blocks/text-block.tsx +75 -0
  63. package/src/components/email-editor/editor-sidebar.tsx +66 -0
  64. package/src/components/email-editor/email-editor.tsx +497 -0
  65. package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
  66. package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
  67. package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
  68. package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
  69. package/src/components/email-editor/index.ts +51 -0
  70. package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
  71. package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
  72. package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
  73. package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
  74. package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
  75. package/src/components/email-editor/panels/index.ts +3 -0
  76. package/src/components/email-editor/renderer/block-renderers.ts +209 -0
  77. package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
  78. package/src/components/email-editor/types.ts +413 -0
  79. package/src/components/email-editor/utils/defaults.ts +116 -0
  80. package/src/components/email-editor/utils/undo-redo.ts +59 -0
  81. package/src/components/enrichment/EnrichButton.tsx +33 -0
  82. package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
  83. package/src/components/enrichment/QualityBadge.tsx +43 -0
  84. package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
  85. package/src/components/enrichment/index.ts +8 -0
  86. package/src/components/gantt/GanttBoardView.tsx +71 -0
  87. package/src/components/gantt/GanttChart.tsx +140 -887
  88. package/src/components/gantt/GanttFilterBar.tsx +100 -0
  89. package/src/components/gantt/GanttListView.tsx +63 -0
  90. package/src/components/gantt/GanttTimelineView.tsx +215 -0
  91. package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
  92. package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
  93. package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
  94. package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
  95. package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
  96. package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
  97. package/src/components/gantt/hooks/useGanttState.ts +644 -0
  98. package/src/components/gantt/index.ts +10 -0
  99. package/src/components/gantt/types.ts +5 -5
  100. package/src/components/index.ts +46 -0
  101. package/src/components/integrations/ConnectionStatus.tsx +77 -0
  102. package/src/components/integrations/IntegrationCard.tsx +92 -0
  103. package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
  104. package/src/components/integrations/index.ts +5 -0
  105. package/src/components/kanban/KanbanBoard.tsx +103 -0
  106. package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
  107. package/src/components/kanban/index.ts +2 -0
  108. package/src/components/lists/CreateListDialog.tsx +158 -0
  109. package/src/components/lists/ListCard.tsx +77 -0
  110. package/src/components/lists/__tests__/lists.test.tsx +263 -0
  111. package/src/components/lists/index.ts +5 -0
  112. package/src/components/loading/__tests__/loading.test.tsx +114 -0
  113. package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
  114. package/src/components/pipeline/StageTransitionModal.tsx +146 -0
  115. package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
  116. package/src/components/pipeline/index.ts +2 -0
  117. package/src/components/settings/SettingsCard.tsx +33 -0
  118. package/src/components/settings/SettingsLayout.tsx +28 -0
  119. package/src/components/settings/SettingsNav.tsx +42 -0
  120. package/src/components/settings/__tests__/settings.test.tsx +181 -0
  121. package/src/components/settings/index.ts +6 -0
  122. package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
@@ -0,0 +1,376 @@
1
+ import { renderToEmailHtml, renderToPreviewHtml } from '../renderer/email-html-renderer'
2
+ import type { Section, GlobalStyles } from '../types'
3
+ import { DEFAULT_GLOBAL_STYLES, createSection, createRow } from '../types'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Fixtures
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function makeSingleSection(overrides: Partial<Section> = {}): Section[] {
10
+ const section: Section = {
11
+ id: 'section-1',
12
+ rows: [
13
+ {
14
+ id: 'row-1',
15
+ layout: '1',
16
+ columns: [
17
+ [
18
+ {
19
+ id: 'text-1',
20
+ type: 'text',
21
+ content: '<p>Hello email</p>',
22
+ },
23
+ ],
24
+ ],
25
+ },
26
+ ],
27
+ paddingTop: 16,
28
+ paddingBottom: 16,
29
+ paddingLeft: 0,
30
+ paddingRight: 0,
31
+ ...overrides,
32
+ }
33
+ return [section]
34
+ }
35
+
36
+ const defaultStyles: GlobalStyles = DEFAULT_GLOBAL_STYLES
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Valid HTML document structure
40
+ // ---------------------------------------------------------------------------
41
+
42
+ describe('renderToEmailHtml — document structure', () => {
43
+ it('starts with <!DOCTYPE html>', () => {
44
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
45
+ expect(html.trimStart()).toMatch(/^<!DOCTYPE html>/i)
46
+ })
47
+
48
+ it('contains <html> root element with lang="en"', () => {
49
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
50
+ expect(html).toContain('<html lang="en"')
51
+ })
52
+
53
+ it('contains <head> with charset meta tag', () => {
54
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
55
+ expect(html).toContain('<meta charset="utf-8">')
56
+ })
57
+
58
+ it('contains viewport meta tag', () => {
59
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
60
+ expect(html).toContain('name="viewport"')
61
+ })
62
+
63
+ it('contains <body> element', () => {
64
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
65
+ expect(html).toContain('<body')
66
+ })
67
+
68
+ it('closes </html> tag', () => {
69
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
70
+ expect(html.trimEnd()).toMatch(/<\/html>$/)
71
+ })
72
+
73
+ it('contains closing </body> tag', () => {
74
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
75
+ expect(html).toContain('</body>')
76
+ })
77
+
78
+ it('contains role="presentation" table for email wrapper', () => {
79
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
80
+ expect(html).toContain('role="presentation"')
81
+ })
82
+
83
+ it('contains email-container class for responsive width', () => {
84
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
85
+ expect(html).toContain('class="email-container"')
86
+ })
87
+
88
+ it('contains @media query for max-width:620px', () => {
89
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
90
+ expect(html).toContain('max-width:620px')
91
+ })
92
+
93
+ it('contains MSO conditional comment for Outlook', () => {
94
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
95
+ expect(html).toContain('[if mso]')
96
+ })
97
+ })
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Global styles applied to output
101
+ // ---------------------------------------------------------------------------
102
+
103
+ describe('renderToEmailHtml — global styles', () => {
104
+ it('uses globalStyles.backgroundColor in body inline style', () => {
105
+ const html = renderToEmailHtml(makeSingleSection(), { ...defaultStyles, backgroundColor: '#abcdef' })
106
+ expect(html).toContain('background-color:#abcdef')
107
+ })
108
+
109
+ it('uses globalStyles.contentWidth for container width attribute', () => {
110
+ const html = renderToEmailHtml(makeSingleSection(), { ...defaultStyles, contentWidth: 700 })
111
+ expect(html).toContain('width="700"')
112
+ expect(html).toContain('max-width:700px')
113
+ })
114
+
115
+ it('uses globalStyles.fontFamily in body inline style', () => {
116
+ const html = renderToEmailHtml(makeSingleSection(), {
117
+ ...defaultStyles,
118
+ fontFamily: 'Georgia, serif',
119
+ })
120
+ expect(html).toContain('font-family:Georgia, serif')
121
+ })
122
+
123
+ it('defaults contentWidth to 600 when not provided', () => {
124
+ const html = renderToEmailHtml(makeSingleSection(), { ...defaultStyles, contentWidth: 0 })
125
+ expect(html).toContain('width="600"')
126
+ })
127
+
128
+ it('defaults backgroundColor when not provided', () => {
129
+ const html = renderToEmailHtml(makeSingleSection(), { ...defaultStyles, backgroundColor: '' })
130
+ // Falls back to the empty string default; body still renders
131
+ expect(html).toContain('<body')
132
+ })
133
+ })
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Subject / preheader options
137
+ // ---------------------------------------------------------------------------
138
+
139
+ describe('renderToEmailHtml — options', () => {
140
+ it('puts subject in <title> tag', () => {
141
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles, { subject: 'Q1 Update' })
142
+ expect(html).toContain('<title>Q1 Update</title>')
143
+ })
144
+
145
+ it('empty subject results in empty <title>', () => {
146
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles, { subject: '' })
147
+ expect(html).toContain('<title></title>')
148
+ })
149
+
150
+ it('renders preheader div when preheaderText is provided', () => {
151
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles, {
152
+ preheaderText: 'Read this important update',
153
+ })
154
+ expect(html).toContain('Read this important update')
155
+ expect(html).toContain('display:none')
156
+ })
157
+
158
+ it('does not render preheader div when preheaderText is absent', () => {
159
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
160
+ expect(html).not.toContain('display:none')
161
+ })
162
+ })
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Block content included in output
166
+ // ---------------------------------------------------------------------------
167
+
168
+ describe('renderToEmailHtml — block content', () => {
169
+ it('includes text block content in output', () => {
170
+ const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
171
+ expect(html).toContain('Hello email')
172
+ })
173
+
174
+ it('includes content from header block', () => {
175
+ const section: Section = {
176
+ id: 's1',
177
+ rows: [
178
+ {
179
+ id: 'r1',
180
+ layout: '1',
181
+ columns: [
182
+ [{ id: 'h1', type: 'header', companyName: 'StartSimpli', alignment: 'center' }],
183
+ ],
184
+ },
185
+ ],
186
+ }
187
+ const html = renderToEmailHtml([section], defaultStyles)
188
+ expect(html).toContain('StartSimpli')
189
+ })
190
+
191
+ it('includes metrics block values', () => {
192
+ const section: Section = {
193
+ id: 's1',
194
+ rows: [
195
+ {
196
+ id: 'r1',
197
+ layout: '1',
198
+ columns: [
199
+ [
200
+ {
201
+ id: 'met1',
202
+ type: 'metrics',
203
+ title: 'My Metrics',
204
+ metrics: [{ id: 'm1', label: 'Revenue', value: '$42k', changeType: 'positive' }],
205
+ columns: 2,
206
+ },
207
+ ],
208
+ ],
209
+ },
210
+ ],
211
+ }
212
+ const html = renderToEmailHtml([section], defaultStyles)
213
+ expect(html).toContain('$42k')
214
+ expect(html).toContain('Revenue')
215
+ })
216
+
217
+ it('includes CTA button text and URL', () => {
218
+ const section: Section = {
219
+ id: 's1',
220
+ rows: [
221
+ {
222
+ id: 'r1',
223
+ layout: '1',
224
+ columns: [
225
+ [
226
+ {
227
+ id: 'cta1',
228
+ type: 'cta',
229
+ text: 'Click Here',
230
+ url: 'https://start.simpli',
231
+ alignment: 'center',
232
+ buttonColor: '#2563eb',
233
+ textColor: '#ffffff',
234
+ borderRadius: 6,
235
+ paddingH: 24,
236
+ paddingV: 12,
237
+ },
238
+ ],
239
+ ],
240
+ },
241
+ ],
242
+ }
243
+ const html = renderToEmailHtml([section], defaultStyles)
244
+ expect(html).toContain('Click Here')
245
+ expect(html).toContain('https://start.simpli')
246
+ })
247
+
248
+ it('includes footer company name', () => {
249
+ const section: Section = {
250
+ id: 's1',
251
+ rows: [
252
+ {
253
+ id: 'r1',
254
+ layout: '1',
255
+ columns: [
256
+ [
257
+ {
258
+ id: 'f1',
259
+ type: 'footer',
260
+ companyName: 'Acme Inc',
261
+ showUnsubscribe: false,
262
+ alignment: 'center',
263
+ },
264
+ ],
265
+ ],
266
+ },
267
+ ],
268
+ }
269
+ const html = renderToEmailHtml([section], defaultStyles)
270
+ expect(html).toContain('Acme Inc')
271
+ })
272
+ })
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Multi-section and multi-column layout
276
+ // ---------------------------------------------------------------------------
277
+
278
+ describe('renderToEmailHtml — layout', () => {
279
+ it('renders multiple sections (both section backgrounds appear)', () => {
280
+ const s1: Section = {
281
+ id: 's1',
282
+ backgroundColor: '#ff0000',
283
+ rows: [{ id: 'r1', layout: '1', columns: [[{ id: 't1', type: 'text', content: 'Sec1' }]] }],
284
+ }
285
+ const s2: Section = {
286
+ id: 's2',
287
+ backgroundColor: '#00ff00',
288
+ rows: [{ id: 'r2', layout: '1', columns: [[{ id: 't2', type: 'text', content: 'Sec2' }]] }],
289
+ }
290
+ const html = renderToEmailHtml([s1, s2], defaultStyles)
291
+ expect(html).toContain('#ff0000')
292
+ expect(html).toContain('#00ff00')
293
+ expect(html).toContain('Sec1')
294
+ expect(html).toContain('Sec2')
295
+ })
296
+
297
+ it('renders multi-column row using nested table with stack-column class', () => {
298
+ const section: Section = {
299
+ id: 's1',
300
+ rows: [
301
+ {
302
+ id: 'r1',
303
+ layout: '2',
304
+ columns: [
305
+ [{ id: 't1', type: 'text', content: 'Left col' }],
306
+ [{ id: 't2', type: 'text', content: 'Right col' }],
307
+ ],
308
+ },
309
+ ],
310
+ }
311
+ const html = renderToEmailHtml([section], defaultStyles)
312
+ expect(html).toContain('class="stack-column"')
313
+ expect(html).toContain('Left col')
314
+ expect(html).toContain('Right col')
315
+ })
316
+
317
+ it('section separator spacer row is present between sections', () => {
318
+ const s1: Section = {
319
+ id: 's1',
320
+ rows: [{ id: 'r1', layout: '1', columns: [[{ id: 't1', type: 'text', content: 'A' }]] }],
321
+ }
322
+ const s2: Section = {
323
+ id: 's2',
324
+ rows: [{ id: 'r2', layout: '1', columns: [[{ id: 't2', type: 'text', content: 'B' }]] }],
325
+ }
326
+ const html = renderToEmailHtml([s1, s2], defaultStyles)
327
+ // 4px spacer row between sections
328
+ expect(html).toContain('height:4px')
329
+ })
330
+
331
+ it('section padding is applied in style attribute', () => {
332
+ const section: Section = {
333
+ id: 's1',
334
+ paddingTop: 32,
335
+ paddingBottom: 24,
336
+ paddingLeft: 8,
337
+ paddingRight: 8,
338
+ rows: [{ id: 'r1', layout: '1', columns: [[{ id: 't1', type: 'text', content: 'X' }]] }],
339
+ }
340
+ const html = renderToEmailHtml([section], defaultStyles)
341
+ expect(html).toContain('padding:32px 8px 24px 8px')
342
+ })
343
+
344
+ it('renders empty sections array without crashing', () => {
345
+ expect(() => renderToEmailHtml([], defaultStyles)).not.toThrow()
346
+ const html = renderToEmailHtml([], defaultStyles)
347
+ expect(html).toContain('<!DOCTYPE html>')
348
+ })
349
+
350
+ it('renders section with empty columns without crashing', () => {
351
+ const section: Section = {
352
+ id: 's1',
353
+ rows: [{ id: 'r1', layout: '1', columns: [[]] }],
354
+ }
355
+ expect(() => renderToEmailHtml([section], defaultStyles)).not.toThrow()
356
+ })
357
+ })
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // renderToPreviewHtml delegates to renderToEmailHtml
361
+ // ---------------------------------------------------------------------------
362
+
363
+ describe('renderToPreviewHtml', () => {
364
+ it('returns the same output as renderToEmailHtml', () => {
365
+ const sections = makeSingleSection()
366
+ expect(renderToPreviewHtml(sections, defaultStyles)).toBe(
367
+ renderToEmailHtml(sections, defaultStyles)
368
+ )
369
+ })
370
+
371
+ it('uses DEFAULT_GLOBAL_STYLES when no styles argument provided', () => {
372
+ const sections = makeSingleSection()
373
+ const html = renderToPreviewHtml(sections)
374
+ expect(html).toContain(`background-color:${DEFAULT_GLOBAL_STYLES.backgroundColor}`)
375
+ })
376
+ })
@@ -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
+ }