@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,226 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { GlobalStylesPanel } from '../GlobalStylesPanel'
|
|
3
|
+
import type { GlobalStyles } from '../../types'
|
|
4
|
+
import { DEFAULT_GLOBAL_STYLES } from '../../types'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Fixtures
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const baseStyles: GlobalStyles = {
|
|
11
|
+
backgroundColor: '#f3f4f6',
|
|
12
|
+
contentWidth: 600,
|
|
13
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
14
|
+
theme: 'clean',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function renderPanel(styles: GlobalStyles = baseStyles, onChange = jest.fn()) {
|
|
18
|
+
return render(<GlobalStylesPanel globalStyles={styles} onChange={onChange} />)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Renders style inputs
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
describe('GlobalStylesPanel — renders style inputs', () => {
|
|
26
|
+
it('renders the Background Color label', () => {
|
|
27
|
+
renderPanel()
|
|
28
|
+
expect(screen.getByText('Background Color')).toBeInTheDocument()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders the background color text input with current value', () => {
|
|
32
|
+
renderPanel()
|
|
33
|
+
const inputs = screen.getAllByDisplayValue('#f3f4f6')
|
|
34
|
+
// At least one text input shows the background color value
|
|
35
|
+
expect(inputs.length).toBeGreaterThan(0)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('renders the Content Width input', () => {
|
|
39
|
+
renderPanel()
|
|
40
|
+
expect(screen.getByText('Content Width (px)')).toBeInTheDocument()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('renders content width number input with current value', () => {
|
|
44
|
+
renderPanel()
|
|
45
|
+
expect(screen.getByDisplayValue('600')).toBeInTheDocument()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders the Font Family label', () => {
|
|
49
|
+
renderPanel()
|
|
50
|
+
expect(screen.getByText('Font Family')).toBeInTheDocument()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('renders the Themes section heading', () => {
|
|
54
|
+
renderPanel()
|
|
55
|
+
expect(screen.getByText('Themes')).toBeInTheDocument()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('renders Clean theme preset button', () => {
|
|
59
|
+
renderPanel()
|
|
60
|
+
expect(screen.getByText('Clean')).toBeInTheDocument()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('renders Minimal theme preset button', () => {
|
|
64
|
+
renderPanel()
|
|
65
|
+
expect(screen.getByText('Minimal')).toBeInTheDocument()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('renders Bold theme preset button', () => {
|
|
69
|
+
renderPanel()
|
|
70
|
+
expect(screen.getByText('Bold')).toBeInTheDocument()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('renders theme description text for each preset', () => {
|
|
74
|
+
renderPanel()
|
|
75
|
+
expect(screen.getByText('White background with subtle gray accents')).toBeInTheDocument()
|
|
76
|
+
expect(screen.getByText('Pure white with thin borders')).toBeInTheDocument()
|
|
77
|
+
expect(screen.getByText('Dark background with vivid accents')).toBeInTheDocument()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('renders color picker input for background color', () => {
|
|
81
|
+
const { container } = renderPanel()
|
|
82
|
+
const colorInput = container.querySelector('input[type="color"]') as HTMLInputElement
|
|
83
|
+
expect(colorInput).toBeInTheDocument()
|
|
84
|
+
expect(colorInput.value).toBe('#f3f4f6')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('renders Email Design section heading', () => {
|
|
88
|
+
renderPanel()
|
|
89
|
+
expect(screen.getByText('Email Design')).toBeInTheDocument()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// onChange fires with updated styles
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
describe('GlobalStylesPanel — onChange fires with updated styles', () => {
|
|
98
|
+
it('calls onChange when background color text input changes', () => {
|
|
99
|
+
const onChange = jest.fn()
|
|
100
|
+
renderPanel(baseStyles, onChange)
|
|
101
|
+
const inputs = screen.getAllByDisplayValue('#f3f4f6')
|
|
102
|
+
// Find the text input (not color picker) — the Input component has type="text" by default
|
|
103
|
+
const textInput = inputs.find(
|
|
104
|
+
(el) => (el as HTMLInputElement).type !== 'color'
|
|
105
|
+
) as HTMLInputElement
|
|
106
|
+
fireEvent.change(textInput, { target: { value: '#ffffff' } })
|
|
107
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
108
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
109
|
+
expect.objectContaining({ backgroundColor: '#ffffff' })
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('calls onChange when color picker changes', () => {
|
|
114
|
+
const onChange = jest.fn()
|
|
115
|
+
const { container } = renderPanel(baseStyles, onChange)
|
|
116
|
+
const colorInput = container.querySelector('input[type="color"]') as HTMLInputElement
|
|
117
|
+
fireEvent.change(colorInput, { target: { value: '#123456' } })
|
|
118
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
119
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
120
|
+
expect.objectContaining({ backgroundColor: '#123456' })
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('calls onChange with parsed integer when content width changes', () => {
|
|
125
|
+
const onChange = jest.fn()
|
|
126
|
+
renderPanel(baseStyles, onChange)
|
|
127
|
+
const widthInput = screen.getByDisplayValue('600')
|
|
128
|
+
fireEvent.change(widthInput, { target: { value: '700' } })
|
|
129
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
130
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
131
|
+
expect.objectContaining({ contentWidth: 700 })
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('defaults contentWidth to 600 when invalid value entered', () => {
|
|
136
|
+
const onChange = jest.fn()
|
|
137
|
+
renderPanel(baseStyles, onChange)
|
|
138
|
+
const widthInput = screen.getByDisplayValue('600')
|
|
139
|
+
fireEvent.change(widthInput, { target: { value: 'abc' } })
|
|
140
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
141
|
+
expect.objectContaining({ contentWidth: 600 })
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('calls onChange with Clean theme preset globalStyles on Clean button click', () => {
|
|
146
|
+
const onChange = jest.fn()
|
|
147
|
+
renderPanel(baseStyles, onChange)
|
|
148
|
+
fireEvent.click(screen.getByText('Clean'))
|
|
149
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
150
|
+
const called = onChange.mock.calls[0][0] as GlobalStyles
|
|
151
|
+
expect(called.theme).toBe('clean')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('calls onChange with Minimal theme preset globalStyles on Minimal button click', () => {
|
|
155
|
+
const onChange = jest.fn()
|
|
156
|
+
renderPanel(baseStyles, onChange)
|
|
157
|
+
fireEvent.click(screen.getByText('Minimal'))
|
|
158
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
159
|
+
const called = onChange.mock.calls[0][0] as GlobalStyles
|
|
160
|
+
expect(called.theme).toBe('minimal')
|
|
161
|
+
expect(called.backgroundColor).toBe('#ffffff')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('calls onChange with Bold theme preset globalStyles on Bold button click', () => {
|
|
165
|
+
const onChange = jest.fn()
|
|
166
|
+
renderPanel(baseStyles, onChange)
|
|
167
|
+
fireEvent.click(screen.getByText('Bold'))
|
|
168
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
169
|
+
const called = onChange.mock.calls[0][0] as GlobalStyles
|
|
170
|
+
expect(called.theme).toBe('bold')
|
|
171
|
+
expect(called.backgroundColor).toBe('#1f2937')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('preserves all other globalStyles fields when only backgroundColor changes', () => {
|
|
175
|
+
const onChange = jest.fn()
|
|
176
|
+
renderPanel(baseStyles, onChange)
|
|
177
|
+
const inputs = screen.getAllByDisplayValue('#f3f4f6')
|
|
178
|
+
const textInput = inputs.find(
|
|
179
|
+
(el) => (el as HTMLInputElement).type !== 'color'
|
|
180
|
+
) as HTMLInputElement
|
|
181
|
+
fireEvent.change(textInput, { target: { value: '#aaaaaa' } })
|
|
182
|
+
const result = onChange.mock.calls[0][0] as GlobalStyles
|
|
183
|
+
expect(result.contentWidth).toBe(baseStyles.contentWidth)
|
|
184
|
+
expect(result.fontFamily).toBe(baseStyles.fontFamily)
|
|
185
|
+
expect(result.theme).toBe(baseStyles.theme)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Active theme highlighting
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
describe('GlobalStylesPanel — active theme highlight', () => {
|
|
194
|
+
it('applies active border class to the currently selected theme button', () => {
|
|
195
|
+
const { container } = renderPanel({ ...baseStyles, theme: 'clean' })
|
|
196
|
+
const cleanButton = screen.getByText('Clean').closest('button') as HTMLElement
|
|
197
|
+
expect(cleanButton.className).toContain('border-primary')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('does not apply active border class to non-selected theme buttons', () => {
|
|
201
|
+
const { container } = renderPanel({ ...baseStyles, theme: 'clean' })
|
|
202
|
+
const minimalButton = screen.getByText('Minimal').closest('button') as HTMLElement
|
|
203
|
+
expect(minimalButton.className).not.toContain('border-primary')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('applies active class to Minimal when minimal theme is selected', () => {
|
|
207
|
+
renderPanel({ ...baseStyles, theme: 'minimal' })
|
|
208
|
+
const minimalButton = screen.getByText('Minimal').closest('button') as HTMLElement
|
|
209
|
+
expect(minimalButton.className).toContain('border-primary')
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// DEFAULT_GLOBAL_STYLES as initial values
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
describe('GlobalStylesPanel — DEFAULT_GLOBAL_STYLES compatibility', () => {
|
|
218
|
+
it('renders correctly with DEFAULT_GLOBAL_STYLES', () => {
|
|
219
|
+
expect(() => renderPanel(DEFAULT_GLOBAL_STYLES)).not.toThrow()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('shows default content width 600', () => {
|
|
223
|
+
renderPanel(DEFAULT_GLOBAL_STYLES)
|
|
224
|
+
expect(screen.getByDisplayValue('600')).toBeInTheDocument()
|
|
225
|
+
})
|
|
226
|
+
})
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Block,
|
|
3
|
+
TextBlock,
|
|
4
|
+
MetricsBlock,
|
|
5
|
+
DividerBlock,
|
|
6
|
+
CTABlock,
|
|
7
|
+
ImageBlock,
|
|
8
|
+
SpacerBlock,
|
|
9
|
+
SocialBlock,
|
|
10
|
+
HeaderBlock,
|
|
11
|
+
FooterBlock,
|
|
12
|
+
GlobalStyles,
|
|
13
|
+
} from '../types'
|
|
14
|
+
|
|
15
|
+
// Render a single block to email-safe HTML (table-based, inline CSS)
|
|
16
|
+
export function renderBlockToHtml(block: Block, globalStyles: GlobalStyles): string {
|
|
17
|
+
switch (block.type) {
|
|
18
|
+
case 'text':
|
|
19
|
+
return renderTextBlock(block, globalStyles)
|
|
20
|
+
case 'metrics':
|
|
21
|
+
return renderMetricsBlock(block)
|
|
22
|
+
case 'divider':
|
|
23
|
+
return renderDividerBlock(block)
|
|
24
|
+
case 'cta':
|
|
25
|
+
return renderButtonBlock(block)
|
|
26
|
+
case 'image':
|
|
27
|
+
return renderImageBlock(block)
|
|
28
|
+
case 'spacer':
|
|
29
|
+
return renderSpacerBlock(block)
|
|
30
|
+
case 'social':
|
|
31
|
+
return renderSocialBlock(block)
|
|
32
|
+
case 'header':
|
|
33
|
+
return renderHeaderBlock(block)
|
|
34
|
+
case 'footer':
|
|
35
|
+
return renderFooterBlock(block)
|
|
36
|
+
default:
|
|
37
|
+
return ''
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function blockPaddingStyle(block: Block): string {
|
|
42
|
+
const s = block.style
|
|
43
|
+
if (!s) return ''
|
|
44
|
+
const parts: string[] = []
|
|
45
|
+
if (s.paddingTop) parts.push(`padding-top:${s.paddingTop}px`)
|
|
46
|
+
if (s.paddingBottom) parts.push(`padding-bottom:${s.paddingBottom}px`)
|
|
47
|
+
if (s.paddingLeft) parts.push(`padding-left:${s.paddingLeft}px`)
|
|
48
|
+
if (s.paddingRight) parts.push(`padding-right:${s.paddingRight}px`)
|
|
49
|
+
if (s.marginTop) parts.push(`margin-top:${s.marginTop}px`)
|
|
50
|
+
if (s.marginBottom) parts.push(`margin-bottom:${s.marginBottom}px`)
|
|
51
|
+
if (s.backgroundColor) parts.push(`background-color:${s.backgroundColor}`)
|
|
52
|
+
return parts.join(';')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderTextBlock(block: TextBlock, globalStyles: GlobalStyles): string {
|
|
56
|
+
const fontFamily = block.fontFamily || globalStyles.fontFamily
|
|
57
|
+
const fontSize = block.fontSize ? `${block.fontSize}px` : '16px'
|
|
58
|
+
const lineHeight = block.lineHeight ? `${block.lineHeight}` : '1.6'
|
|
59
|
+
const color = block.textColor || '#1f2937'
|
|
60
|
+
const extraStyle = blockPaddingStyle(block)
|
|
61
|
+
|
|
62
|
+
return `<div style="font-family:${fontFamily};font-size:${fontSize};line-height:${lineHeight};color:${color};${extraStyle}">${block.content}</div>`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderMetricsBlock(block: MetricsBlock): string {
|
|
66
|
+
const cols = block.columns || 2
|
|
67
|
+
const extraStyle = blockPaddingStyle(block)
|
|
68
|
+
|
|
69
|
+
const title = block.title
|
|
70
|
+
? `<div style="text-align:center;margin-bottom:16px;font-size:18px;font-weight:600;${extraStyle}">${block.title}</div>`
|
|
71
|
+
: ''
|
|
72
|
+
|
|
73
|
+
const cells = block.metrics
|
|
74
|
+
.map((m) => {
|
|
75
|
+
const changeColor =
|
|
76
|
+
m.changeType === 'positive'
|
|
77
|
+
? '#16a34a'
|
|
78
|
+
: m.changeType === 'negative'
|
|
79
|
+
? '#dc2626'
|
|
80
|
+
: '#6b7280'
|
|
81
|
+
const changeHtml = m.change
|
|
82
|
+
? `<div style="font-size:12px;color:${changeColor};margin-top:4px;">${m.change}</div>`
|
|
83
|
+
: ''
|
|
84
|
+
return `<td style="text-align:center;padding:16px;background:#f9fafb;border-radius:8px;width:${Math.floor(100 / cols)}%;">
|
|
85
|
+
<div style="font-size:24px;font-weight:bold;">${m.value}</div>
|
|
86
|
+
<div style="font-size:14px;color:#6b7280;margin-top:4px;">${m.label}</div>
|
|
87
|
+
${changeHtml}
|
|
88
|
+
</td>`
|
|
89
|
+
})
|
|
90
|
+
.join('<td style="width:16px;"></td>')
|
|
91
|
+
|
|
92
|
+
return `${title}<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;margin:16px 0;"><tr>${cells}</tr></table>`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderDividerBlock(block: DividerBlock): string {
|
|
96
|
+
const extraStyle = blockPaddingStyle(block)
|
|
97
|
+
if (block.dividerStyle === 'space') {
|
|
98
|
+
return `<div style="height:32px;${extraStyle}"></div>`
|
|
99
|
+
}
|
|
100
|
+
const color = block.color || '#d1d5db'
|
|
101
|
+
const thickness = block.thickness || 1
|
|
102
|
+
const width = block.width || 100
|
|
103
|
+
return `<table width="100%" cellpadding="0" cellspacing="0" style="margin:16px 0;${extraStyle}"><tr><td align="center"><div style="width:${width}%;border-top:${thickness}px ${block.dividerStyle} ${color};"></div></td></tr></table>`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderButtonBlock(block: CTABlock): string {
|
|
107
|
+
const bg = block.buttonColor || '#2563eb'
|
|
108
|
+
const color = block.textColor || '#ffffff'
|
|
109
|
+
const radius = block.borderRadius ?? 6
|
|
110
|
+
const paddingH = block.paddingH ?? 24
|
|
111
|
+
const paddingV = block.paddingV ?? 12
|
|
112
|
+
const extraStyle = blockPaddingStyle(block)
|
|
113
|
+
|
|
114
|
+
const align = block.alignment || 'center'
|
|
115
|
+
return `<table width="100%" cellpadding="0" cellspacing="0" style="margin:16px 0;${extraStyle}"><tr><td align="${align}">
|
|
116
|
+
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" href="${block.url || '#'}" style="height:${paddingV * 2 + 20}px;v-text-anchor:middle;width:auto;" arcsize="${Math.round((radius / (paddingV * 2 + 20)) * 100)}%" strokecolor="${bg}" fillcolor="${bg}"><center style="color:${color};font-family:sans-serif;font-size:14px;font-weight:500;">${block.text}</center></v:roundrect><![endif]-->
|
|
117
|
+
<!--[if !mso]><!-->
|
|
118
|
+
<a href="${block.url || '#'}" style="display:inline-block;padding:${paddingV}px ${paddingH}px;background-color:${bg};color:${color};border-radius:${radius}px;text-decoration:none;font-weight:500;font-size:14px;font-family:sans-serif;">${block.text}</a>
|
|
119
|
+
<!--<![endif]-->
|
|
120
|
+
</td></tr></table>`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderImageBlock(block: ImageBlock): string {
|
|
124
|
+
if (!block.url) return ''
|
|
125
|
+
const width = block.width || 100
|
|
126
|
+
const align = block.alignment || 'center'
|
|
127
|
+
const extraStyle = blockPaddingStyle(block)
|
|
128
|
+
const captionHtml = block.caption
|
|
129
|
+
? `<div style="font-size:14px;color:#6b7280;margin-top:8px;text-align:center;">${block.caption}</div>`
|
|
130
|
+
: ''
|
|
131
|
+
|
|
132
|
+
const imgTag = `<img src="${block.url}" alt="${block.alt || ''}" width="${Math.round(600 * (width / 100))}" style="display:block;max-width:100%;height:auto;border-radius:4px;" />`
|
|
133
|
+
const linked = block.linkUrl
|
|
134
|
+
? `<a href="${block.linkUrl}" target="_blank">${imgTag}</a>`
|
|
135
|
+
: imgTag
|
|
136
|
+
|
|
137
|
+
return `<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;${extraStyle}"><tr><td align="${align}">${linked}${captionHtml}</td></tr></table>`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function renderSpacerBlock(block: SpacerBlock): string {
|
|
141
|
+
const height = block.height || 32
|
|
142
|
+
return `<div style="height:${height}px;line-height:${height}px;font-size:1px;"> </div>`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderSocialBlock(block: SocialBlock): string {
|
|
146
|
+
const align = block.alignment || 'center'
|
|
147
|
+
const size = block.iconSize || 24
|
|
148
|
+
const extraStyle = blockPaddingStyle(block)
|
|
149
|
+
|
|
150
|
+
// Using text-based fallback icons for email compatibility
|
|
151
|
+
const platformLabels: Record<string, string> = {
|
|
152
|
+
linkedin: 'LinkedIn',
|
|
153
|
+
twitter: 'Twitter',
|
|
154
|
+
facebook: 'Facebook',
|
|
155
|
+
instagram: 'Instagram',
|
|
156
|
+
youtube: 'YouTube',
|
|
157
|
+
github: 'GitHub',
|
|
158
|
+
website: 'Website',
|
|
159
|
+
}
|
|
160
|
+
const platformColors: Record<string, string> = {
|
|
161
|
+
linkedin: '#0A66C2',
|
|
162
|
+
twitter: '#1DA1F2',
|
|
163
|
+
facebook: '#1877F2',
|
|
164
|
+
instagram: '#E4405F',
|
|
165
|
+
youtube: '#FF0000',
|
|
166
|
+
github: '#333333',
|
|
167
|
+
website: '#6b7280',
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const links = block.links
|
|
171
|
+
.filter((l) => l.url)
|
|
172
|
+
.map((l) => {
|
|
173
|
+
const label = platformLabels[l.platform] || l.platform
|
|
174
|
+
const color = platformColors[l.platform] || '#6b7280'
|
|
175
|
+
return `<td style="padding:0 6px;"><a href="${l.url}" target="_blank" style="color:${color};text-decoration:none;font-size:${Math.max(12, size - 8)}px;font-weight:500;">${label}</a></td>`
|
|
176
|
+
})
|
|
177
|
+
.join('')
|
|
178
|
+
|
|
179
|
+
if (!links) return ''
|
|
180
|
+
return `<table width="100%" cellpadding="0" cellspacing="0" style="margin:16px 0;${extraStyle}"><tr><td align="${align}"><table cellpadding="0" cellspacing="0"><tr>${links}</tr></table></td></tr></table>`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function renderHeaderBlock(block: HeaderBlock): string {
|
|
184
|
+
const align = block.alignment || 'center'
|
|
185
|
+
const extraStyle = blockPaddingStyle(block)
|
|
186
|
+
const logo = block.logoUrl
|
|
187
|
+
? `<img src="${block.logoUrl}" alt="${block.companyName}" style="height:40px;width:auto;margin-right:12px;vertical-align:middle;" />`
|
|
188
|
+
: ''
|
|
189
|
+
return `<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;${extraStyle}"><tr><td align="${align}" style="padding:16px 0;">
|
|
190
|
+
${logo}<span style="font-size:20px;font-weight:600;vertical-align:middle;">${block.companyName}</span>
|
|
191
|
+
</td></tr></table>`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderFooterBlock(block: FooterBlock): string {
|
|
195
|
+
const align = block.alignment || 'center'
|
|
196
|
+
const extraStyle = blockPaddingStyle(block)
|
|
197
|
+
const addressHtml = block.address
|
|
198
|
+
? `<div style="margin-top:4px;">${block.address}</div>`
|
|
199
|
+
: ''
|
|
200
|
+
const unsubHtml = block.showUnsubscribe
|
|
201
|
+
? `<div style="margin-top:8px;"><a href="${block.unsubscribeUrl || '#'}" style="color:#6b7280;text-decoration:underline;">Unsubscribe</a> from these emails</div>`
|
|
202
|
+
: ''
|
|
203
|
+
|
|
204
|
+
return `<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;${extraStyle}"><tr><td align="${align}" style="font-size:12px;color:#6b7280;padding:16px 0;">
|
|
205
|
+
<div>${block.companyName}</div>
|
|
206
|
+
${addressHtml}
|
|
207
|
+
${unsubHtml}
|
|
208
|
+
</td></tr></table>`
|
|
209
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Section, GlobalStyles, DEFAULT_GLOBAL_STYLES, getColumnWidths } from '../types'
|
|
2
|
+
import { renderBlockToHtml } from './block-renderers'
|
|
3
|
+
|
|
4
|
+
export interface RenderOptions {
|
|
5
|
+
subject?: string
|
|
6
|
+
preheaderText?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Converts sections to a full email-safe HTML document.
|
|
11
|
+
* Uses table-based layout with inline CSS for maximum email client compatibility.
|
|
12
|
+
*/
|
|
13
|
+
export function renderToEmailHtml(
|
|
14
|
+
sections: Section[],
|
|
15
|
+
globalStyles: GlobalStyles = DEFAULT_GLOBAL_STYLES,
|
|
16
|
+
options: RenderOptions = {}
|
|
17
|
+
): string {
|
|
18
|
+
const { subject = '', preheaderText = '' } = options
|
|
19
|
+
const contentWidth = globalStyles.contentWidth || 600
|
|
20
|
+
const bgColor = globalStyles.backgroundColor || '#f3f4f6'
|
|
21
|
+
const fontFamily = globalStyles.fontFamily || '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
|
22
|
+
|
|
23
|
+
const sectionsHtml = sections.map((section) => renderSection(section, globalStyles, contentWidth)).join('')
|
|
24
|
+
|
|
25
|
+
const preheader = preheaderText
|
|
26
|
+
? `<div style="display:none;font-size:1px;color:${bgColor};line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">${preheaderText}</div>`
|
|
27
|
+
: ''
|
|
28
|
+
|
|
29
|
+
return `<!DOCTYPE html>
|
|
30
|
+
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
31
|
+
<head>
|
|
32
|
+
<meta charset="utf-8">
|
|
33
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
34
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
35
|
+
<meta name="x-apple-disable-message-reformatting">
|
|
36
|
+
<title>${subject}</title>
|
|
37
|
+
<!--[if mso]>
|
|
38
|
+
<noscript>
|
|
39
|
+
<xml>
|
|
40
|
+
<o:OfficeDocumentSettings>
|
|
41
|
+
<o:AllowPNG/>
|
|
42
|
+
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
43
|
+
</o:OfficeDocumentSettings>
|
|
44
|
+
</xml>
|
|
45
|
+
</noscript>
|
|
46
|
+
<![endif]-->
|
|
47
|
+
<style type="text/css">
|
|
48
|
+
body { margin:0; padding:0; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; }
|
|
49
|
+
table, td { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; }
|
|
50
|
+
img { -ms-interpolation-mode:bicubic; border:0; height:auto; line-height:100%; outline:none; text-decoration:none; }
|
|
51
|
+
a { color:inherit; }
|
|
52
|
+
@media only screen and (max-width:620px) {
|
|
53
|
+
.email-container { width:100% !important; max-width:100% !important; }
|
|
54
|
+
.stack-column { display:block !important; width:100% !important; max-width:100% !important; }
|
|
55
|
+
.stack-column-center { text-align:center !important; }
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body style="margin:0;padding:0;background-color:${bgColor};font-family:${fontFamily};-webkit-font-smoothing:antialiased;">
|
|
60
|
+
${preheader}
|
|
61
|
+
<!-- Email wrapper table -->
|
|
62
|
+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:${bgColor};">
|
|
63
|
+
<tr>
|
|
64
|
+
<td align="center" style="padding:24px 16px;">
|
|
65
|
+
<!-- Content container -->
|
|
66
|
+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="${contentWidth}" class="email-container" style="max-width:${contentWidth}px;width:100%;">
|
|
67
|
+
${sectionsHtml}
|
|
68
|
+
</table>
|
|
69
|
+
</td>
|
|
70
|
+
</tr>
|
|
71
|
+
</table>
|
|
72
|
+
</body>
|
|
73
|
+
</html>`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderSection(section: Section, globalStyles: GlobalStyles, contentWidth: number): string {
|
|
77
|
+
const bgColor = section.backgroundColor || '#ffffff'
|
|
78
|
+
const pt = section.paddingTop ?? 16
|
|
79
|
+
const pb = section.paddingBottom ?? 16
|
|
80
|
+
const pl = section.paddingLeft ?? 0
|
|
81
|
+
const pr = section.paddingRight ?? 0
|
|
82
|
+
|
|
83
|
+
const rowsHtml = section.rows.map((row) => {
|
|
84
|
+
const widths = getColumnWidths(row.layout)
|
|
85
|
+
const colCount = widths.length
|
|
86
|
+
|
|
87
|
+
if (colCount === 1) {
|
|
88
|
+
// Single column: simple
|
|
89
|
+
const blocksHtml = row.columns[0]
|
|
90
|
+
?.map((block) => renderBlockToHtml(block, globalStyles))
|
|
91
|
+
.join('') || ''
|
|
92
|
+
return `<tr><td style="padding:0;">${blocksHtml}</td></tr>`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Multi-column: use nested table
|
|
96
|
+
const columnsHtml = widths
|
|
97
|
+
.map((widthPct, i) => {
|
|
98
|
+
const colBlocks = row.columns[i] || []
|
|
99
|
+
const blocksHtml = colBlocks
|
|
100
|
+
.map((block) => renderBlockToHtml(block, globalStyles))
|
|
101
|
+
.join('')
|
|
102
|
+
const pxWidth = Math.round((contentWidth - pl - pr) * (widthPct / 100))
|
|
103
|
+
return `<td class="stack-column" valign="top" width="${pxWidth}" style="width:${widthPct}%;padding:0 4px;vertical-align:top;">${blocksHtml}</td>`
|
|
104
|
+
})
|
|
105
|
+
.join('')
|
|
106
|
+
|
|
107
|
+
return `<tr><td style="padding:0;">
|
|
108
|
+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"><tr>${columnsHtml}</tr></table>
|
|
109
|
+
</td></tr>`
|
|
110
|
+
}).join('')
|
|
111
|
+
|
|
112
|
+
return `<tr><td style="background-color:${bgColor};padding:${pt}px ${pr}px ${pb}px ${pl}px;border-radius:8px;">
|
|
113
|
+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
|
114
|
+
${rowsHtml}
|
|
115
|
+
</table>
|
|
116
|
+
</td></tr>
|
|
117
|
+
<tr><td style="height:4px;font-size:1px;line-height:1px;"> </td></tr>`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Render sections to a simple HTML string (for preview iframe, not for sending).
|
|
122
|
+
*/
|
|
123
|
+
export function renderToPreviewHtml(
|
|
124
|
+
sections: Section[],
|
|
125
|
+
globalStyles: GlobalStyles = DEFAULT_GLOBAL_STYLES
|
|
126
|
+
): string {
|
|
127
|
+
return renderToEmailHtml(sections, globalStyles)
|
|
128
|
+
}
|