@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.
- package/package.json +2 -1
- package/src/__mocks__/next/link.js +11 -0
- package/src/components/ActivityTimeline.tsx +173 -0
- package/src/components/LogActivityDialog.tsx +303 -0
- package/src/components/QuickLogButtons.tsx +32 -0
- package/src/components/account/__tests__/account.test.tsx +315 -0
- package/src/components/badge/StageBadge.tsx +31 -0
- package/src/components/badge/index.ts +3 -0
- package/src/components/command-palette/CommandGroup.tsx +23 -0
- package/src/components/command-palette/CommandPalette.tsx +327 -0
- package/src/components/command-palette/CommandResultItem.tsx +59 -0
- package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
- package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
- package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
- package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
- package/src/components/command-palette/command-palette-context.tsx +51 -0
- package/src/components/command-palette/index.ts +9 -0
- package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
- package/src/components/compose/__tests__/compose.test.tsx +656 -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/PipelineFunnel.tsx +126 -0
- package/src/components/dashboard/SparklineTrend.tsx +102 -0
- package/src/components/dashboard/TopCampaigns.tsx +132 -0
- package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
- package/src/components/dashboard/index.ts +20 -0
- package/src/components/dialog/ConfirmDialog.tsx +72 -0
- package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
- package/src/components/dialog/index.ts +3 -0
- package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -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/BlockRenderer.tsx +120 -0
- package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
- package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
- package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -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/__tests__/blocks.test.tsx +818 -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 +66 -0
- package/src/components/email-editor/email-editor.tsx +497 -0
- package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
- package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
- package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
- package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
- package/src/components/email-editor/index.ts +51 -0
- package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
- package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
- package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
- package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
- package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
- package/src/components/email-editor/panels/index.ts +3 -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/__tests__/enrichment.test.tsx +184 -0
- package/src/components/enrichment/index.ts +8 -0
- package/src/components/gantt/GanttBoardView.tsx +71 -0
- package/src/components/gantt/GanttChart.tsx +140 -887
- package/src/components/gantt/GanttFilterBar.tsx +100 -0
- package/src/components/gantt/GanttListView.tsx +63 -0
- package/src/components/gantt/GanttTimelineView.tsx +215 -0
- package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
- package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
- package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
- package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
- package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
- package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
- package/src/components/gantt/hooks/useGanttState.ts +644 -0
- package/src/components/gantt/index.ts +10 -0
- 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/__tests__/integrations.test.tsx +191 -0
- package/src/components/integrations/index.ts +5 -0
- package/src/components/kanban/KanbanBoard.tsx +103 -0
- package/src/components/kanban/__tests__/kanban.test.tsx +157 -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/__tests__/lists.test.tsx +263 -0
- package/src/components/lists/index.ts +5 -0
- package/src/components/loading/__tests__/loading.test.tsx +114 -0
- package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
- package/src/components/pipeline/StageTransitionModal.tsx +146 -0
- package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -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/__tests__/settings.test.tsx +181 -0
- package/src/components/settings/index.ts +6 -0
- 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
|
+
}
|