@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,707 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { BlockPropertyPanel } from '../BlockPropertyPanel'
3
+ import type {
4
+ TextBlock,
5
+ DividerBlock,
6
+ CTABlock,
7
+ ImageBlock,
8
+ SpacerBlock,
9
+ SocialBlock,
10
+ HeaderBlock,
11
+ FooterBlock,
12
+ Block,
13
+ } from '../../types'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Fixtures
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const textBlock: TextBlock = {
20
+ id: 'text-1',
21
+ type: 'text',
22
+ content: '<p>Hello</p>',
23
+ fontSize: 16,
24
+ lineHeight: 1.6,
25
+ textColor: '#1f2937',
26
+ }
27
+
28
+ const dividerBlock: DividerBlock = {
29
+ id: 'divider-1',
30
+ type: 'divider',
31
+ dividerStyle: 'solid',
32
+ color: '#d1d5db',
33
+ thickness: 1,
34
+ width: 100,
35
+ }
36
+
37
+ const ctaBlock: CTABlock = {
38
+ id: 'cta-1',
39
+ type: 'cta',
40
+ text: 'Buy Now',
41
+ url: 'https://buy.example.com',
42
+ buttonColor: '#2563eb',
43
+ textColor: '#ffffff',
44
+ borderRadius: 6,
45
+ paddingH: 24,
46
+ paddingV: 12,
47
+ alignment: 'center',
48
+ }
49
+
50
+ const imageBlock: ImageBlock = {
51
+ id: 'image-1',
52
+ type: 'image',
53
+ url: 'https://img.example.com/pic.jpg',
54
+ alt: 'My image',
55
+ alignment: 'center',
56
+ width: 100,
57
+ }
58
+
59
+ const spacerBlock: SpacerBlock = {
60
+ id: 'spacer-1',
61
+ type: 'spacer',
62
+ height: 32,
63
+ }
64
+
65
+ const socialBlock: SocialBlock = {
66
+ id: 'social-1',
67
+ type: 'social',
68
+ links: [
69
+ { id: 'sl1', platform: 'linkedin', url: 'https://linkedin.com' },
70
+ ],
71
+ iconSize: 24,
72
+ alignment: 'center',
73
+ }
74
+
75
+ const headerBlock: HeaderBlock = {
76
+ id: 'header-1',
77
+ type: 'header',
78
+ companyName: 'PropCo',
79
+ logoUrl: '',
80
+ alignment: 'center',
81
+ }
82
+
83
+ const footerBlock: FooterBlock = {
84
+ id: 'footer-1',
85
+ type: 'footer',
86
+ companyName: 'PropCo',
87
+ address: '1 Main St',
88
+ showUnsubscribe: true,
89
+ unsubscribeUrl: 'https://unsub.example.com',
90
+ alignment: 'center',
91
+ }
92
+
93
+ function renderPanel(block: Block, onChange = jest.fn()) {
94
+ return { ...render(<BlockPropertyPanel block={block} onChange={onChange} />), onChange }
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Renders type label header
99
+ // ---------------------------------------------------------------------------
100
+
101
+ describe('BlockPropertyPanel — type label header', () => {
102
+ it('shows "text Settings" heading for text block', () => {
103
+ renderPanel(textBlock)
104
+ expect(screen.getByText('text Settings')).toBeInTheDocument()
105
+ })
106
+
107
+ it('shows "divider Settings" heading for divider block', () => {
108
+ renderPanel(dividerBlock)
109
+ expect(screen.getByText('divider Settings')).toBeInTheDocument()
110
+ })
111
+
112
+ it('shows "cta Settings" heading for cta block', () => {
113
+ renderPanel(ctaBlock)
114
+ expect(screen.getByText('cta Settings')).toBeInTheDocument()
115
+ })
116
+
117
+ it('shows "image Settings" heading for image block', () => {
118
+ renderPanel(imageBlock)
119
+ expect(screen.getByText('image Settings')).toBeInTheDocument()
120
+ })
121
+
122
+ it('shows "spacer Settings" heading for spacer block', () => {
123
+ renderPanel(spacerBlock)
124
+ expect(screen.getByText('spacer Settings')).toBeInTheDocument()
125
+ })
126
+
127
+ it('shows "social Settings" heading for social block', () => {
128
+ renderPanel(socialBlock)
129
+ expect(screen.getByText('social Settings')).toBeInTheDocument()
130
+ })
131
+
132
+ it('shows "header Settings" heading for header block', () => {
133
+ renderPanel(headerBlock)
134
+ expect(screen.getByText('header Settings')).toBeInTheDocument()
135
+ })
136
+
137
+ it('shows "footer Settings" heading for footer block', () => {
138
+ renderPanel(footerBlock)
139
+ expect(screen.getByText('footer Settings')).toBeInTheDocument()
140
+ })
141
+ })
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Common spacing / background section (present for all block types)
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe('BlockPropertyPanel — common spacing section', () => {
148
+ it('renders Spacing heading for text block', () => {
149
+ renderPanel(textBlock)
150
+ expect(screen.getByText('Spacing')).toBeInTheDocument()
151
+ })
152
+
153
+ it('renders Pad Top input for any block', () => {
154
+ renderPanel(spacerBlock)
155
+ expect(screen.getByText('Pad Top')).toBeInTheDocument()
156
+ })
157
+
158
+ it('renders Pad Bottom input for any block', () => {
159
+ renderPanel(ctaBlock)
160
+ expect(screen.getByText('Pad Bottom')).toBeInTheDocument()
161
+ })
162
+
163
+ it('renders Background label for any block', () => {
164
+ renderPanel(headerBlock)
165
+ expect(screen.getByText('Background')).toBeInTheDocument()
166
+ })
167
+
168
+ it('calls onChange with updated paddingTop when Pad Top input changes', () => {
169
+ const onChange = jest.fn()
170
+ render(<BlockPropertyPanel block={textBlock} onChange={onChange} />)
171
+ // Both Pad Top and Pad Bottom show "0"; take the first (Pad Top)
172
+ const padTopInput = screen.getAllByDisplayValue('0')[0]
173
+ fireEvent.change(padTopInput, { target: { value: '12' } })
174
+ expect(onChange).toHaveBeenCalledTimes(1)
175
+ expect(onChange).toHaveBeenCalledWith(
176
+ expect.objectContaining({
177
+ style: expect.objectContaining({ paddingTop: 12 }),
178
+ })
179
+ )
180
+ })
181
+
182
+ it('calls onChange with updated paddingBottom when Pad Bottom input changes', () => {
183
+ const onChange = jest.fn()
184
+ render(<BlockPropertyPanel block={textBlock} onChange={onChange} />)
185
+ const inputs = screen.getAllByDisplayValue('0')
186
+ // Second "0" input is Pad Bottom
187
+ fireEvent.change(inputs[1], { target: { value: '8' } })
188
+ expect(onChange).toHaveBeenCalledTimes(1)
189
+ expect(onChange).toHaveBeenCalledWith(
190
+ expect.objectContaining({
191
+ style: expect.objectContaining({ paddingBottom: 8 }),
192
+ })
193
+ )
194
+ })
195
+
196
+ it('calls onChange with updated backgroundColor from background text input', () => {
197
+ const onChange = jest.fn()
198
+ render(<BlockPropertyPanel block={textBlock} onChange={onChange} />)
199
+ const bgInput = screen.getByPlaceholderText('transparent')
200
+ fireEvent.change(bgInput, { target: { value: '#eeeeee' } })
201
+ expect(onChange).toHaveBeenCalledTimes(1)
202
+ expect(onChange).toHaveBeenCalledWith(
203
+ expect.objectContaining({
204
+ style: expect.objectContaining({ backgroundColor: '#eeeeee' }),
205
+ })
206
+ )
207
+ })
208
+ })
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // TextSettings — correct property editors rendered
212
+ // ---------------------------------------------------------------------------
213
+
214
+ describe('BlockPropertyPanel — TextSettings for text block', () => {
215
+ it('renders Font Size input', () => {
216
+ renderPanel(textBlock)
217
+ expect(screen.getByText('Font Size (px)')).toBeInTheDocument()
218
+ })
219
+
220
+ it('renders Line Height input', () => {
221
+ renderPanel(textBlock)
222
+ expect(screen.getByText('Line Height')).toBeInTheDocument()
223
+ })
224
+
225
+ it('renders Text Color label', () => {
226
+ renderPanel(textBlock)
227
+ expect(screen.getAllByText('Text Color').length).toBeGreaterThan(0)
228
+ })
229
+
230
+ it('renders Font Family label', () => {
231
+ renderPanel(textBlock)
232
+ expect(screen.getAllByText('Font Family').length).toBeGreaterThan(0)
233
+ })
234
+
235
+ it('calls onChange with updated fontSize when Font Size input changes', () => {
236
+ const onChange = jest.fn()
237
+ render(<BlockPropertyPanel block={textBlock} onChange={onChange} />)
238
+ const fontSizeInput = screen.getByDisplayValue('16')
239
+ fireEvent.change(fontSizeInput, { target: { value: '20' } })
240
+ expect(onChange).toHaveBeenCalledWith(
241
+ expect.objectContaining({ type: 'text', fontSize: 20 })
242
+ )
243
+ })
244
+
245
+ it('calls onChange with updated lineHeight when Line Height input changes', () => {
246
+ const onChange = jest.fn()
247
+ render(<BlockPropertyPanel block={textBlock} onChange={onChange} />)
248
+ const lineHeightInput = screen.getByDisplayValue('1.6')
249
+ fireEvent.change(lineHeightInput, { target: { value: '2' } })
250
+ expect(onChange).toHaveBeenCalledWith(
251
+ expect.objectContaining({ type: 'text', lineHeight: 2 })
252
+ )
253
+ })
254
+
255
+ it('calls onChange with updated textColor when text color input changes', () => {
256
+ const onChange = jest.fn()
257
+ render(<BlockPropertyPanel block={textBlock} onChange={onChange} />)
258
+ const colorInputs = screen.getAllByDisplayValue('#1f2937')
259
+ const textInput = colorInputs.find(
260
+ (el) => (el as HTMLInputElement).type !== 'color'
261
+ ) as HTMLInputElement
262
+ fireEvent.change(textInput, { target: { value: '#ff0000' } })
263
+ expect(onChange).toHaveBeenCalledWith(
264
+ expect.objectContaining({ type: 'text', textColor: '#ff0000' })
265
+ )
266
+ })
267
+
268
+ it('does not render DividerSettings for text block', () => {
269
+ renderPanel(textBlock)
270
+ expect(screen.queryByText('Thickness (px)')).not.toBeInTheDocument()
271
+ })
272
+ })
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // DividerSettings
276
+ // ---------------------------------------------------------------------------
277
+
278
+ describe('BlockPropertyPanel — DividerSettings for divider block', () => {
279
+ it('renders Style select', () => {
280
+ renderPanel(dividerBlock)
281
+ expect(screen.getByText('Style')).toBeInTheDocument()
282
+ })
283
+
284
+ it('renders Thickness input', () => {
285
+ renderPanel(dividerBlock)
286
+ expect(screen.getByText('Thickness (px)')).toBeInTheDocument()
287
+ })
288
+
289
+ it('renders Width input', () => {
290
+ renderPanel(dividerBlock)
291
+ // There are potentially two "Width" labels but divider shows "Width (%)"
292
+ expect(screen.getByText('Width (%)')).toBeInTheDocument()
293
+ })
294
+
295
+ it('calls onChange with updated thickness', () => {
296
+ const onChange = jest.fn()
297
+ render(<BlockPropertyPanel block={dividerBlock} onChange={onChange} />)
298
+ const thicknessInput = screen.getByDisplayValue('1')
299
+ fireEvent.change(thicknessInput, { target: { value: '3' } })
300
+ expect(onChange).toHaveBeenCalledWith(
301
+ expect.objectContaining({ type: 'divider', thickness: 3 })
302
+ )
303
+ })
304
+
305
+ it('calls onChange with updated width percentage', () => {
306
+ const onChange = jest.fn()
307
+ render(<BlockPropertyPanel block={dividerBlock} onChange={onChange} />)
308
+ const widthInput = screen.getByDisplayValue('100')
309
+ fireEvent.change(widthInput, { target: { value: '80' } })
310
+ expect(onChange).toHaveBeenCalledWith(
311
+ expect.objectContaining({ type: 'divider', width: 80 })
312
+ )
313
+ })
314
+
315
+ it('calls onChange with updated color from text input', () => {
316
+ const onChange = jest.fn()
317
+ render(<BlockPropertyPanel block={dividerBlock} onChange={onChange} />)
318
+ const colorInputs = screen.getAllByDisplayValue('#d1d5db')
319
+ const textInput = colorInputs.find(
320
+ (el) => (el as HTMLInputElement).type !== 'color'
321
+ ) as HTMLInputElement
322
+ fireEvent.change(textInput, { target: { value: '#000000' } })
323
+ expect(onChange).toHaveBeenCalledWith(
324
+ expect.objectContaining({ type: 'divider', color: '#000000' })
325
+ )
326
+ })
327
+
328
+ it('does not render TextSettings for divider block', () => {
329
+ renderPanel(dividerBlock)
330
+ expect(screen.queryByText('Font Size (px)')).not.toBeInTheDocument()
331
+ })
332
+ })
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // ButtonSettings (CTABlock)
336
+ // ---------------------------------------------------------------------------
337
+
338
+ describe('BlockPropertyPanel — ButtonSettings for cta block', () => {
339
+ it('renders Button Text input', () => {
340
+ renderPanel(ctaBlock)
341
+ expect(screen.getByText('Button Text')).toBeInTheDocument()
342
+ })
343
+
344
+ it('renders URL input', () => {
345
+ renderPanel(ctaBlock)
346
+ expect(screen.getByText('URL')).toBeInTheDocument()
347
+ })
348
+
349
+ it('renders Button Color label', () => {
350
+ renderPanel(ctaBlock)
351
+ expect(screen.getByText('Button Color')).toBeInTheDocument()
352
+ })
353
+
354
+ it('renders Border Radius input', () => {
355
+ renderPanel(ctaBlock)
356
+ expect(screen.getByText('Border Radius (px)')).toBeInTheDocument()
357
+ })
358
+
359
+ it('renders Pad H and Pad V inputs', () => {
360
+ renderPanel(ctaBlock)
361
+ expect(screen.getByText('Pad H')).toBeInTheDocument()
362
+ expect(screen.getByText('Pad V')).toBeInTheDocument()
363
+ })
364
+
365
+ it('shows button text value in input', () => {
366
+ renderPanel(ctaBlock)
367
+ expect(screen.getByDisplayValue('Buy Now')).toBeInTheDocument()
368
+ })
369
+
370
+ it('calls onChange with updated text when Button Text input changes', () => {
371
+ const onChange = jest.fn()
372
+ render(<BlockPropertyPanel block={ctaBlock} onChange={onChange} />)
373
+ const textInput = screen.getByDisplayValue('Buy Now')
374
+ fireEvent.change(textInput, { target: { value: 'Shop Now' } })
375
+ expect(onChange).toHaveBeenCalledWith(
376
+ expect.objectContaining({ type: 'cta', text: 'Shop Now' })
377
+ )
378
+ })
379
+
380
+ it('calls onChange with updated URL when URL input changes', () => {
381
+ const onChange = jest.fn()
382
+ render(<BlockPropertyPanel block={ctaBlock} onChange={onChange} />)
383
+ const urlInput = screen.getByDisplayValue('https://buy.example.com')
384
+ fireEvent.change(urlInput, { target: { value: 'https://new.example.com' } })
385
+ expect(onChange).toHaveBeenCalledWith(
386
+ expect.objectContaining({ type: 'cta', url: 'https://new.example.com' })
387
+ )
388
+ })
389
+
390
+ it('calls onChange with updated borderRadius', () => {
391
+ const onChange = jest.fn()
392
+ render(<BlockPropertyPanel block={ctaBlock} onChange={onChange} />)
393
+ const radiusInput = screen.getByDisplayValue('6')
394
+ fireEvent.change(radiusInput, { target: { value: '12' } })
395
+ expect(onChange).toHaveBeenCalledWith(
396
+ expect.objectContaining({ type: 'cta', borderRadius: 12 })
397
+ )
398
+ })
399
+ })
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // ImageSettings
403
+ // ---------------------------------------------------------------------------
404
+
405
+ describe('BlockPropertyPanel — ImageSettings for image block', () => {
406
+ it('renders Image URL label', () => {
407
+ renderPanel(imageBlock)
408
+ expect(screen.getByText('Image URL')).toBeInTheDocument()
409
+ })
410
+
411
+ it('renders Alt Text label', () => {
412
+ renderPanel(imageBlock)
413
+ expect(screen.getByText('Alt Text')).toBeInTheDocument()
414
+ })
415
+
416
+ it('renders Caption label', () => {
417
+ renderPanel(imageBlock)
418
+ expect(screen.getByText('Caption')).toBeInTheDocument()
419
+ })
420
+
421
+ it('renders Link URL label', () => {
422
+ renderPanel(imageBlock)
423
+ expect(screen.getByText('Link URL (wraps image)')).toBeInTheDocument()
424
+ })
425
+
426
+ it('renders Width (%) input', () => {
427
+ renderPanel(imageBlock)
428
+ expect(screen.getByText('Width (%)')).toBeInTheDocument()
429
+ })
430
+
431
+ it('shows image URL value in input', () => {
432
+ renderPanel(imageBlock)
433
+ expect(screen.getByDisplayValue('https://img.example.com/pic.jpg')).toBeInTheDocument()
434
+ })
435
+
436
+ it('calls onChange with updated URL', () => {
437
+ const onChange = jest.fn()
438
+ render(<BlockPropertyPanel block={imageBlock} onChange={onChange} />)
439
+ const urlInput = screen.getByDisplayValue('https://img.example.com/pic.jpg')
440
+ fireEvent.change(urlInput, { target: { value: 'https://new.img.com/x.png' } })
441
+ expect(onChange).toHaveBeenCalledWith(
442
+ expect.objectContaining({ type: 'image', url: 'https://new.img.com/x.png' })
443
+ )
444
+ })
445
+
446
+ it('calls onChange with updated alt text', () => {
447
+ const onChange = jest.fn()
448
+ render(<BlockPropertyPanel block={imageBlock} onChange={onChange} />)
449
+ const altInput = screen.getByDisplayValue('My image')
450
+ fireEvent.change(altInput, { target: { value: 'Updated alt' } })
451
+ expect(onChange).toHaveBeenCalledWith(
452
+ expect.objectContaining({ type: 'image', alt: 'Updated alt' })
453
+ )
454
+ })
455
+
456
+ it('calls onChange with updated width percentage', () => {
457
+ const onChange = jest.fn()
458
+ render(<BlockPropertyPanel block={imageBlock} onChange={onChange} />)
459
+ const widthInput = screen.getByDisplayValue('100')
460
+ fireEvent.change(widthInput, { target: { value: '75' } })
461
+ expect(onChange).toHaveBeenCalledWith(
462
+ expect.objectContaining({ type: 'image', width: 75 })
463
+ )
464
+ })
465
+ })
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // SpacerSettings
469
+ // ---------------------------------------------------------------------------
470
+
471
+ describe('BlockPropertyPanel — SpacerSettings for spacer block', () => {
472
+ it('renders Height (px) label', () => {
473
+ renderPanel(spacerBlock)
474
+ expect(screen.getByText('Height (px)')).toBeInTheDocument()
475
+ })
476
+
477
+ it('shows spacer height value in input', () => {
478
+ renderPanel(spacerBlock)
479
+ expect(screen.getByDisplayValue('32')).toBeInTheDocument()
480
+ })
481
+
482
+ it('calls onChange with updated height', () => {
483
+ const onChange = jest.fn()
484
+ render(<BlockPropertyPanel block={spacerBlock} onChange={onChange} />)
485
+ const heightInput = screen.getByDisplayValue('32')
486
+ fireEvent.change(heightInput, { target: { value: '64' } })
487
+ expect(onChange).toHaveBeenCalledWith(
488
+ expect.objectContaining({ type: 'spacer', height: 64 })
489
+ )
490
+ })
491
+
492
+ it('does not render text-specific inputs for spacer', () => {
493
+ renderPanel(spacerBlock)
494
+ expect(screen.queryByText('Font Size (px)')).not.toBeInTheDocument()
495
+ expect(screen.queryByText('Button Text')).not.toBeInTheDocument()
496
+ })
497
+ })
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // SocialSettings
501
+ // ---------------------------------------------------------------------------
502
+
503
+ describe('BlockPropertyPanel — SocialSettings for social block', () => {
504
+ it('renders Alignment label', () => {
505
+ renderPanel(socialBlock)
506
+ expect(screen.getByText('Alignment')).toBeInTheDocument()
507
+ })
508
+
509
+ it('renders Icon Size label', () => {
510
+ renderPanel(socialBlock)
511
+ expect(screen.getByText('Icon Size')).toBeInTheDocument()
512
+ })
513
+
514
+ it('renders Links label', () => {
515
+ renderPanel(socialBlock)
516
+ expect(screen.getByText('Links')).toBeInTheDocument()
517
+ })
518
+
519
+ it('renders Add Link button', () => {
520
+ renderPanel(socialBlock)
521
+ expect(screen.getByText('Add Link')).toBeInTheDocument()
522
+ })
523
+
524
+ it('calls onChange when Add Link is clicked', () => {
525
+ const onChange = jest.fn()
526
+ render(<BlockPropertyPanel block={socialBlock} onChange={onChange} />)
527
+ fireEvent.click(screen.getByText('Add Link'))
528
+ expect(onChange).toHaveBeenCalledTimes(1)
529
+ const updated = onChange.mock.calls[0][0] as SocialBlock
530
+ expect(updated.links).toHaveLength(2)
531
+ })
532
+
533
+ it('calls onChange when remove link button is clicked', () => {
534
+ const onChange = jest.fn()
535
+ render(<BlockPropertyPanel block={socialBlock} onChange={onChange} />)
536
+ // The Trash2 icon button — there is one link so one remove button
537
+ const removeButton = screen.getAllByRole('button').find(
538
+ (btn) => btn.querySelector('svg') !== null && !btn.textContent?.includes('Add')
539
+ ) as HTMLElement
540
+ fireEvent.click(removeButton)
541
+ expect(onChange).toHaveBeenCalledTimes(1)
542
+ const updated = onChange.mock.calls[0][0] as SocialBlock
543
+ expect(updated.links).toHaveLength(0)
544
+ })
545
+
546
+ it('shows existing link URL in input', () => {
547
+ renderPanel(socialBlock)
548
+ expect(screen.getByDisplayValue('https://linkedin.com')).toBeInTheDocument()
549
+ })
550
+
551
+ it('calls onChange when link URL input changes', () => {
552
+ const onChange = jest.fn()
553
+ render(<BlockPropertyPanel block={socialBlock} onChange={onChange} />)
554
+ const urlInput = screen.getByDisplayValue('https://linkedin.com')
555
+ fireEvent.change(urlInput, { target: { value: 'https://new-linkedin.com' } })
556
+ expect(onChange).toHaveBeenCalledWith(
557
+ expect.objectContaining({
558
+ type: 'social',
559
+ links: expect.arrayContaining([
560
+ expect.objectContaining({ url: 'https://new-linkedin.com' }),
561
+ ]),
562
+ })
563
+ )
564
+ })
565
+
566
+ it('calls onChange with updated iconSize', () => {
567
+ const onChange = jest.fn()
568
+ render(<BlockPropertyPanel block={socialBlock} onChange={onChange} />)
569
+ const iconSizeInput = screen.getByDisplayValue('24')
570
+ fireEvent.change(iconSizeInput, { target: { value: '32' } })
571
+ expect(onChange).toHaveBeenCalledWith(
572
+ expect.objectContaining({ type: 'social', iconSize: 32 })
573
+ )
574
+ })
575
+ })
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // HeaderSettings
579
+ // ---------------------------------------------------------------------------
580
+
581
+ describe('BlockPropertyPanel — HeaderSettings for header block', () => {
582
+ it('renders Company Name label', () => {
583
+ renderPanel(headerBlock)
584
+ expect(screen.getByText('Company Name')).toBeInTheDocument()
585
+ })
586
+
587
+ it('renders Logo URL label', () => {
588
+ renderPanel(headerBlock)
589
+ expect(screen.getByText('Logo URL')).toBeInTheDocument()
590
+ })
591
+
592
+ it('shows company name value in input', () => {
593
+ renderPanel(headerBlock)
594
+ expect(screen.getByDisplayValue('PropCo')).toBeInTheDocument()
595
+ })
596
+
597
+ it('calls onChange with updated companyName', () => {
598
+ const onChange = jest.fn()
599
+ render(<BlockPropertyPanel block={headerBlock} onChange={onChange} />)
600
+ const nameInput = screen.getByDisplayValue('PropCo')
601
+ fireEvent.change(nameInput, { target: { value: 'NewCo' } })
602
+ expect(onChange).toHaveBeenCalledWith(
603
+ expect.objectContaining({ type: 'header', companyName: 'NewCo' })
604
+ )
605
+ })
606
+
607
+ it('calls onChange with updated logoUrl', () => {
608
+ const onChange = jest.fn()
609
+ render(<BlockPropertyPanel block={headerBlock} onChange={onChange} />)
610
+ // logoUrl input has placeholder "https://..."
611
+ const logoInputs = screen.getAllByPlaceholderText('https://...')
612
+ fireEvent.change(logoInputs[0], { target: { value: 'https://cdn.example.com/logo.png' } })
613
+ expect(onChange).toHaveBeenCalledWith(
614
+ expect.objectContaining({ type: 'header', logoUrl: 'https://cdn.example.com/logo.png' })
615
+ )
616
+ })
617
+ })
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // FooterSettings
621
+ // ---------------------------------------------------------------------------
622
+
623
+ describe('BlockPropertyPanel — FooterSettings for footer block', () => {
624
+ it('renders Company Name label', () => {
625
+ renderPanel(footerBlock)
626
+ // The footer has its own Company Name, header might share the name
627
+ const labels = screen.getAllByText('Company Name')
628
+ expect(labels.length).toBeGreaterThan(0)
629
+ })
630
+
631
+ it('renders Address label', () => {
632
+ renderPanel(footerBlock)
633
+ expect(screen.getByText('Address')).toBeInTheDocument()
634
+ })
635
+
636
+ it('shows address value in input', () => {
637
+ renderPanel(footerBlock)
638
+ expect(screen.getByDisplayValue('1 Main St')).toBeInTheDocument()
639
+ })
640
+
641
+ it('renders "Show unsubscribe link" checkbox', () => {
642
+ renderPanel(footerBlock)
643
+ expect(screen.getByText('Show unsubscribe link')).toBeInTheDocument()
644
+ })
645
+
646
+ it('renders Unsubscribe URL input when showUnsubscribe is true', () => {
647
+ renderPanel(footerBlock)
648
+ expect(screen.getByText('Unsubscribe URL')).toBeInTheDocument()
649
+ })
650
+
651
+ it('does not render Unsubscribe URL input when showUnsubscribe is false', () => {
652
+ const block: FooterBlock = { ...footerBlock, showUnsubscribe: false }
653
+ renderPanel(block)
654
+ expect(screen.queryByText('Unsubscribe URL')).not.toBeInTheDocument()
655
+ })
656
+
657
+ it('calls onChange with updated companyName', () => {
658
+ const onChange = jest.fn()
659
+ render(<BlockPropertyPanel block={footerBlock} onChange={onChange} />)
660
+ const nameInput = screen.getByDisplayValue('PropCo')
661
+ fireEvent.change(nameInput, { target: { value: 'Updated Co' } })
662
+ expect(onChange).toHaveBeenCalledWith(
663
+ expect.objectContaining({ type: 'footer', companyName: 'Updated Co' })
664
+ )
665
+ })
666
+
667
+ it('calls onChange when showUnsubscribe checkbox is toggled off', () => {
668
+ const onChange = jest.fn()
669
+ render(<BlockPropertyPanel block={footerBlock} onChange={onChange} />)
670
+ const checkbox = screen.getByRole('checkbox')
671
+ fireEvent.click(checkbox)
672
+ expect(onChange).toHaveBeenCalledWith(
673
+ expect.objectContaining({ type: 'footer', showUnsubscribe: false })
674
+ )
675
+ })
676
+
677
+ it('calls onChange when showUnsubscribe checkbox is toggled on from false state', () => {
678
+ const onChange = jest.fn()
679
+ const block: FooterBlock = { ...footerBlock, showUnsubscribe: false }
680
+ render(<BlockPropertyPanel block={block} onChange={onChange} />)
681
+ const checkbox = screen.getByRole('checkbox')
682
+ fireEvent.click(checkbox)
683
+ expect(onChange).toHaveBeenCalledWith(
684
+ expect.objectContaining({ type: 'footer', showUnsubscribe: true })
685
+ )
686
+ })
687
+
688
+ it('calls onChange with updated address', () => {
689
+ const onChange = jest.fn()
690
+ render(<BlockPropertyPanel block={footerBlock} onChange={onChange} />)
691
+ const addressInput = screen.getByDisplayValue('1 Main St')
692
+ fireEvent.change(addressInput, { target: { value: '2 New Rd' } })
693
+ expect(onChange).toHaveBeenCalledWith(
694
+ expect.objectContaining({ type: 'footer', address: '2 New Rd' })
695
+ )
696
+ })
697
+
698
+ it('calls onChange with updated unsubscribeUrl', () => {
699
+ const onChange = jest.fn()
700
+ render(<BlockPropertyPanel block={footerBlock} onChange={onChange} />)
701
+ const unsubInput = screen.getByDisplayValue('https://unsub.example.com')
702
+ fireEvent.change(unsubInput, { target: { value: 'https://new-unsub.com' } })
703
+ expect(onChange).toHaveBeenCalledWith(
704
+ expect.objectContaining({ type: 'footer', unsubscribeUrl: 'https://new-unsub.com' })
705
+ )
706
+ })
707
+ })