@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,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,3 @@
1
+ export { GlobalStylesPanel } from './GlobalStylesPanel'
2
+ export { BlockPropertyPanel } from './BlockPropertyPanel'
3
+ export { SectionSettingsPanel } from './SectionSettingsPanel'
@@ -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;">&nbsp;</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;">&nbsp;</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
+ }