@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,188 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from '../ui/dialog'
12
+ import { Button } from '../ui/button'
13
+ import { Input } from '../ui/input'
14
+ import { Label } from '../ui/label'
15
+ import { Checkbox } from '../ui/checkbox'
16
+ import {
17
+ Select,
18
+ SelectContent,
19
+ SelectItem,
20
+ SelectTrigger,
21
+ SelectValue,
22
+ } from '../ui/select'
23
+ import { Loader2, Send, AlertCircle } from 'lucide-react'
24
+ import { useToast } from '../toast'
25
+
26
+ interface TestSendRecipient {
27
+ id: string
28
+ name: string
29
+ email?: string | null
30
+ }
31
+
32
+ interface TestSendPayload {
33
+ testEmail: string
34
+ selectedRecipientId: string | undefined
35
+ includeTracking: boolean
36
+ }
37
+
38
+ interface TestSendDialogProps {
39
+ open: boolean
40
+ onOpenChange: (open: boolean) => void
41
+ subject: string
42
+ /** Sample recipients whose data can be used for merge field substitution */
43
+ recipients?: TestSendRecipient[]
44
+ /** Default sample option label shown when no specific recipient is chosen */
45
+ defaultSampleLabel?: string
46
+ /** Callback to send the test email. Receives the test send configuration. */
47
+ onSendTest: (payload: TestSendPayload) => Promise<void>
48
+ /** Merge field help text */
49
+ mergeFieldHint?: string
50
+ }
51
+
52
+ export function TestSendDialog({
53
+ open,
54
+ onOpenChange,
55
+ subject,
56
+ recipients = [],
57
+ defaultSampleLabel = 'Sample Data (John Smith)',
58
+ onSendTest,
59
+ mergeFieldHint = 'Merge fields like {{recipient.firstName}} will use this recipient\'s data',
60
+ }: TestSendDialogProps) {
61
+ const { toast } = useToast()
62
+ const [sending, setSending] = useState(false)
63
+ const [testEmail, setTestEmail] = useState('')
64
+ const [selectedRecipientId, setSelectedRecipientId] = useState<string>('sample')
65
+ const [includeTracking, setIncludeTracking] = useState(false)
66
+
67
+ const handleSendTest = async () => {
68
+ if (!testEmail) {
69
+ toast({ description: 'Please enter a test email address', variant: 'destructive' })
70
+ return
71
+ }
72
+
73
+ setSending(true)
74
+ try {
75
+ await onSendTest({
76
+ testEmail,
77
+ selectedRecipientId: selectedRecipientId !== 'sample' ? selectedRecipientId : undefined,
78
+ includeTracking,
79
+ })
80
+
81
+ toast({
82
+ description: `Test email sent to ${testEmail}`,
83
+ })
84
+ onOpenChange(false)
85
+ } catch (error) {
86
+ console.error('Error sending test:', error)
87
+ toast({
88
+ description: error instanceof Error ? error.message : 'Failed to send test email',
89
+ variant: 'destructive'
90
+ })
91
+ } finally {
92
+ setSending(false)
93
+ }
94
+ }
95
+
96
+ return (
97
+ <Dialog open={open} onOpenChange={onOpenChange}>
98
+ <DialogContent>
99
+ <DialogHeader>
100
+ <DialogTitle>Send Test Email</DialogTitle>
101
+ <DialogDescription>
102
+ Send a test version to yourself to preview how it will look in your inbox.
103
+ </DialogDescription>
104
+ </DialogHeader>
105
+
106
+ <div className="space-y-4 py-4">
107
+ {/* Subject preview */}
108
+ <div className="p-3 bg-muted rounded-lg">
109
+ <Label className="text-xs text-muted-foreground">Subject</Label>
110
+ <p className="text-sm font-medium mt-1">[TEST] {subject || '(no subject)'}</p>
111
+ </div>
112
+
113
+ {/* Test email input */}
114
+ <div className="space-y-2">
115
+ <Label htmlFor="test-email">Send to</Label>
116
+ <Input
117
+ id="test-email"
118
+ type="email"
119
+ value={testEmail}
120
+ onChange={(e) => setTestEmail(e.target.value)}
121
+ placeholder="your-email@example.com"
122
+ />
123
+ </div>
124
+
125
+ {/* Sample recipient data for merge fields */}
126
+ <div className="space-y-2">
127
+ <Label>Use data from</Label>
128
+ <Select value={selectedRecipientId} onValueChange={setSelectedRecipientId}>
129
+ <SelectTrigger>
130
+ <SelectValue placeholder="Select recipient for merge fields" />
131
+ </SelectTrigger>
132
+ <SelectContent>
133
+ <SelectItem value="sample">{defaultSampleLabel}</SelectItem>
134
+ {recipients.map(r => (
135
+ <SelectItem key={r.id} value={r.id}>
136
+ {r.name} {r.email ? `(${r.email})` : ''}
137
+ </SelectItem>
138
+ ))}
139
+ </SelectContent>
140
+ </Select>
141
+ <p className="text-xs text-muted-foreground">
142
+ {mergeFieldHint}
143
+ </p>
144
+ </div>
145
+
146
+ {/* Tracking option */}
147
+ <div className="flex items-center space-x-2">
148
+ <Checkbox
149
+ id="include-tracking"
150
+ checked={includeTracking}
151
+ onCheckedChange={(checked) => setIncludeTracking(checked as boolean)}
152
+ />
153
+ <Label htmlFor="include-tracking" className="text-sm font-normal">
154
+ Include tracking pixel and link tracking
155
+ </Label>
156
+ </div>
157
+
158
+ {/* Warning */}
159
+ <div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
160
+ <AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
161
+ <p className="text-sm">
162
+ Test emails are marked with [TEST] in the subject and include a banner indicating it&apos;s a test.
163
+ </p>
164
+ </div>
165
+ </div>
166
+
167
+ <DialogFooter>
168
+ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>
169
+ Cancel
170
+ </Button>
171
+ <Button onClick={handleSendTest} disabled={sending || !testEmail}>
172
+ {sending ? (
173
+ <>
174
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
175
+ Sending...
176
+ </>
177
+ ) : (
178
+ <>
179
+ <Send className="mr-2 h-4 w-4" />
180
+ Send Test
181
+ </>
182
+ )}
183
+ </Button>
184
+ </DialogFooter>
185
+ </DialogContent>
186
+ </Dialog>
187
+ )
188
+ }
@@ -0,0 +1,120 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Block, MergeFieldDefinition } from './types'
5
+ import {
6
+ TextBlockEditor,
7
+ MetricsBlockEditor,
8
+ DividerBlockEditor,
9
+ ButtonBlockEditor,
10
+ ImageBlockEditor,
11
+ SpacerBlockEditor,
12
+ SocialBlockEditor,
13
+ HeaderBlockEditor,
14
+ FooterBlockEditor,
15
+ } from './blocks'
16
+
17
+ interface BlockRendererProps {
18
+ block: Block
19
+ editing: boolean
20
+ onChange: (updates: Partial<Block>) => void
21
+ mergeFields: MergeFieldDefinition[]
22
+ /** Optional custom rich text editor for the text block. */
23
+ renderTextEditor?: (props: {
24
+ content: string
25
+ onChange: (html: string) => void
26
+ placeholder?: string
27
+ className?: string
28
+ }) => React.ReactNode
29
+ }
30
+
31
+ export function BlockRenderer({
32
+ block,
33
+ editing,
34
+ onChange,
35
+ mergeFields: _mergeFields,
36
+ renderTextEditor,
37
+ }: BlockRendererProps) {
38
+ const commonProps = {
39
+ isEditing: editing,
40
+ onChange: onChange as (block: Block) => void,
41
+ }
42
+
43
+ switch (block.type) {
44
+ case 'text':
45
+ return (
46
+ <TextBlockEditor
47
+ block={block}
48
+ {...commonProps}
49
+ onChange={(b) => onChange(b)}
50
+ renderEditor={renderTextEditor}
51
+ />
52
+ )
53
+ case 'metrics':
54
+ return (
55
+ <MetricsBlockEditor
56
+ block={block}
57
+ {...commonProps}
58
+ onChange={(b) => onChange(b)}
59
+ />
60
+ )
61
+ case 'divider':
62
+ return (
63
+ <DividerBlockEditor
64
+ block={block}
65
+ {...commonProps}
66
+ onChange={(b) => onChange(b)}
67
+ />
68
+ )
69
+ case 'cta':
70
+ return (
71
+ <ButtonBlockEditor
72
+ block={block}
73
+ {...commonProps}
74
+ onChange={(b) => onChange(b)}
75
+ />
76
+ )
77
+ case 'image':
78
+ return (
79
+ <ImageBlockEditor
80
+ block={block}
81
+ {...commonProps}
82
+ onChange={(b) => onChange(b)}
83
+ />
84
+ )
85
+ case 'spacer':
86
+ return (
87
+ <SpacerBlockEditor
88
+ block={block}
89
+ {...commonProps}
90
+ onChange={(b) => onChange(b)}
91
+ />
92
+ )
93
+ case 'social':
94
+ return (
95
+ <SocialBlockEditor
96
+ block={block}
97
+ {...commonProps}
98
+ onChange={(b) => onChange(b)}
99
+ />
100
+ )
101
+ case 'header':
102
+ return (
103
+ <HeaderBlockEditor
104
+ block={block}
105
+ {...commonProps}
106
+ onChange={(b) => onChange(b)}
107
+ />
108
+ )
109
+ case 'footer':
110
+ return (
111
+ <FooterBlockEditor
112
+ block={block}
113
+ {...commonProps}
114
+ onChange={(b) => onChange(b)}
115
+ />
116
+ )
117
+ default:
118
+ return null
119
+ }
120
+ }
@@ -0,0 +1,332 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { BlockRenderer } from '../BlockRenderer'
3
+ import type {
4
+ TextBlock,
5
+ MetricsBlock,
6
+ DividerBlock,
7
+ CTABlock,
8
+ ImageBlock,
9
+ SpacerBlock,
10
+ SocialBlock,
11
+ HeaderBlock,
12
+ FooterBlock,
13
+ Block,
14
+ } from '../types'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Fixtures
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const textBlock: TextBlock = {
21
+ id: 'text-1',
22
+ type: 'text',
23
+ content: '<p>Hello dispatch</p>',
24
+ }
25
+
26
+ const metricsBlock: MetricsBlock = {
27
+ id: 'metrics-1',
28
+ type: 'metrics',
29
+ title: 'Dispatch Metrics',
30
+ metrics: [
31
+ { id: 'm1', label: 'ARR', value: '$5k', changeType: 'positive' },
32
+ ],
33
+ columns: 2,
34
+ }
35
+
36
+ const dividerBlock: DividerBlock = {
37
+ id: 'divider-1',
38
+ type: 'divider',
39
+ dividerStyle: 'solid',
40
+ color: '#d1d5db',
41
+ thickness: 1,
42
+ width: 100,
43
+ }
44
+
45
+ const ctaBlock: CTABlock = {
46
+ id: 'cta-1',
47
+ type: 'cta',
48
+ text: 'Dispatch CTA',
49
+ url: 'https://example.com',
50
+ buttonColor: '#2563eb',
51
+ textColor: '#ffffff',
52
+ borderRadius: 6,
53
+ paddingH: 24,
54
+ paddingV: 12,
55
+ alignment: 'center',
56
+ }
57
+
58
+ const imageBlock: ImageBlock = {
59
+ id: 'image-1',
60
+ type: 'image',
61
+ url: 'https://example.com/img.png',
62
+ alt: 'dispatch img',
63
+ alignment: 'center',
64
+ width: 100,
65
+ }
66
+
67
+ const spacerBlock: SpacerBlock = {
68
+ id: 'spacer-1',
69
+ type: 'spacer',
70
+ height: 40,
71
+ }
72
+
73
+ const socialBlock: SocialBlock = {
74
+ id: 'social-1',
75
+ type: 'social',
76
+ links: [{ id: 'sl1', platform: 'linkedin', url: 'https://linkedin.com/test' }],
77
+ iconSize: 24,
78
+ alignment: 'center',
79
+ }
80
+
81
+ const headerBlock: HeaderBlock = {
82
+ id: 'header-1',
83
+ type: 'header',
84
+ companyName: 'DispatchCo',
85
+ alignment: 'center',
86
+ }
87
+
88
+ const footerBlock: FooterBlock = {
89
+ id: 'footer-1',
90
+ type: 'footer',
91
+ companyName: 'DispatchCo',
92
+ showUnsubscribe: false,
93
+ alignment: 'center',
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Helper
98
+ // ---------------------------------------------------------------------------
99
+
100
+ function renderBlock(block: Block, editing = false) {
101
+ return render(
102
+ <BlockRenderer
103
+ block={block}
104
+ editing={editing}
105
+ onChange={jest.fn()}
106
+ mergeFields={[]}
107
+ />
108
+ )
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Dispatches to correct editor
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe('BlockRenderer — dispatches to correct block editor', () => {
116
+ it('renders TextBlockEditor for text block (view mode shows .prose)', () => {
117
+ const { container } = renderBlock(textBlock, false)
118
+ expect(container.querySelector('.prose')).toBeInTheDocument()
119
+ })
120
+
121
+ it('renders TextBlockEditor for text block (editing mode shows contentEditable)', () => {
122
+ const { container } = renderBlock(textBlock, true)
123
+ expect(container.querySelector('[contenteditable="true"]')).toBeInTheDocument()
124
+ })
125
+
126
+ it('renders MetricsBlockEditor for metrics block — shows title', () => {
127
+ renderBlock(metricsBlock, false)
128
+ expect(screen.getByText('Dispatch Metrics')).toBeInTheDocument()
129
+ })
130
+
131
+ it('renders MetricsBlockEditor for metrics block — shows metric value', () => {
132
+ renderBlock(metricsBlock, false)
133
+ expect(screen.getByText('$5k')).toBeInTheDocument()
134
+ })
135
+
136
+ it('renders DividerBlockEditor for divider block — shows hr element', () => {
137
+ const { container } = renderBlock(dividerBlock, false)
138
+ expect(container.querySelector('hr')).toBeInTheDocument()
139
+ })
140
+
141
+ it('renders ButtonBlockEditor for cta block — shows button text', () => {
142
+ renderBlock(ctaBlock, false)
143
+ expect(screen.getByText('Dispatch CTA')).toBeInTheDocument()
144
+ })
145
+
146
+ it('renders ImageBlockEditor for image block — shows img element', () => {
147
+ const { container } = renderBlock(imageBlock, false)
148
+ expect(container.querySelector('img')).toBeInTheDocument()
149
+ })
150
+
151
+ it('renders SpacerBlockEditor for spacer block — shows correct height in editing mode', () => {
152
+ renderBlock(spacerBlock, true)
153
+ expect(screen.getByText('40px')).toBeInTheDocument()
154
+ })
155
+
156
+ it('renders SocialBlockEditor for social block — shows social icon wrapper', () => {
157
+ const { container } = renderBlock(socialBlock, false)
158
+ expect(container.querySelector('div')).toBeInTheDocument()
159
+ // LinkedIn link present since url is set
160
+ expect(container.querySelector('a')).toBeInTheDocument()
161
+ })
162
+
163
+ it('renders HeaderBlockEditor for header block — shows company name', () => {
164
+ renderBlock(headerBlock, false)
165
+ expect(screen.getByText('DispatchCo')).toBeInTheDocument()
166
+ })
167
+
168
+ it('renders FooterBlockEditor for footer block — shows company name', () => {
169
+ renderBlock(footerBlock, false)
170
+ // Footer renders company name
171
+ expect(screen.getByText('DispatchCo')).toBeInTheDocument()
172
+ })
173
+ })
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // editing prop is forwarded correctly
177
+ // ---------------------------------------------------------------------------
178
+
179
+ describe('BlockRenderer — editing prop forwarding', () => {
180
+ it('text block: editing=false renders view mode (.prose)', () => {
181
+ const { container } = renderBlock(textBlock, false)
182
+ expect(container.querySelector('.prose')).toBeInTheDocument()
183
+ expect(container.querySelector('[contenteditable="true"]')).not.toBeInTheDocument()
184
+ })
185
+
186
+ it('text block: editing=true renders edit mode (contentEditable)', () => {
187
+ const { container } = renderBlock(textBlock, true)
188
+ expect(container.querySelector('[contenteditable="true"]')).toBeInTheDocument()
189
+ })
190
+
191
+ it('spacer block: editing=false renders plain div without label text', () => {
192
+ const { container } = renderBlock(spacerBlock, false)
193
+ // view mode renders a plain height div, no "px" label
194
+ expect(screen.queryByText('40px')).not.toBeInTheDocument()
195
+ const spacer = container.querySelector('div') as HTMLElement
196
+ expect(spacer.style.height).toBe('40px')
197
+ })
198
+
199
+ it('spacer block: editing=true shows height label', () => {
200
+ renderBlock(spacerBlock, true)
201
+ expect(screen.getByText('40px')).toBeInTheDocument()
202
+ })
203
+
204
+ it('image block: editing=true shows placeholder when url empty', () => {
205
+ const emptyImage: ImageBlock = { ...imageBlock, url: '' }
206
+ render(
207
+ <BlockRenderer
208
+ block={emptyImage}
209
+ editing={true}
210
+ onChange={jest.fn()}
211
+ mergeFields={[]}
212
+ />
213
+ )
214
+ expect(screen.getByText('Click to set image URL')).toBeInTheDocument()
215
+ })
216
+ })
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // onChange is forwarded through to child editors
220
+ // ---------------------------------------------------------------------------
221
+
222
+ describe('BlockRenderer — onChange forwarding', () => {
223
+ it('text block: onChange is called when contentEditable blurs', () => {
224
+ const onChange = jest.fn()
225
+ const { container } = render(
226
+ <BlockRenderer
227
+ block={textBlock}
228
+ editing={true}
229
+ onChange={onChange}
230
+ mergeFields={[]}
231
+ />
232
+ )
233
+ const editable = container.querySelector('[contenteditable="true"]') as HTMLElement
234
+ fireEvent.blur(editable)
235
+ expect(onChange).toHaveBeenCalledTimes(1)
236
+ expect(onChange).toHaveBeenCalledWith(
237
+ expect.objectContaining({ type: 'text', id: 'text-1' })
238
+ )
239
+ })
240
+
241
+ it('metrics block: onChange is called when title input changes', () => {
242
+ const onChange = jest.fn()
243
+ render(
244
+ <BlockRenderer
245
+ block={metricsBlock}
246
+ editing={true}
247
+ onChange={onChange}
248
+ mergeFields={[]}
249
+ />
250
+ )
251
+ const titleInput = screen.getByPlaceholderText('Key Metrics')
252
+ fireEvent.change(titleInput, { target: { value: 'New Title' } })
253
+ expect(onChange).toHaveBeenCalledTimes(1)
254
+ expect(onChange).toHaveBeenCalledWith(
255
+ expect.objectContaining({ type: 'metrics', title: 'New Title' })
256
+ )
257
+ })
258
+ })
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // renderTextEditor prop is forwarded to TextBlockEditor
262
+ // ---------------------------------------------------------------------------
263
+
264
+ describe('BlockRenderer — renderTextEditor prop', () => {
265
+ it('renders custom editor when renderTextEditor is provided for text block', () => {
266
+ const renderTextEditor = jest.fn(() => <div data-testid="custom-rich-editor" />)
267
+ render(
268
+ <BlockRenderer
269
+ block={textBlock}
270
+ editing={true}
271
+ onChange={jest.fn()}
272
+ mergeFields={[]}
273
+ renderTextEditor={renderTextEditor}
274
+ />
275
+ )
276
+ expect(screen.getByTestId('custom-rich-editor')).toBeInTheDocument()
277
+ expect(renderTextEditor).toHaveBeenCalledWith(
278
+ expect.objectContaining({
279
+ content: textBlock.content,
280
+ })
281
+ )
282
+ })
283
+
284
+ it('renderTextEditor is ignored for non-text block types', () => {
285
+ const renderTextEditor = jest.fn(() => <div data-testid="custom-rich-editor" />)
286
+ render(
287
+ <BlockRenderer
288
+ block={dividerBlock}
289
+ editing={true}
290
+ onChange={jest.fn()}
291
+ mergeFields={[]}
292
+ renderTextEditor={renderTextEditor}
293
+ />
294
+ )
295
+ // DividerBlockEditor should render instead
296
+ expect(screen.queryByTestId('custom-rich-editor')).not.toBeInTheDocument()
297
+ const { container } = render(
298
+ <BlockRenderer
299
+ block={dividerBlock}
300
+ editing={true}
301
+ onChange={jest.fn()}
302
+ mergeFields={[]}
303
+ renderTextEditor={renderTextEditor}
304
+ />
305
+ )
306
+ expect(container.querySelector('hr')).toBeInTheDocument()
307
+ expect(renderTextEditor).not.toHaveBeenCalled()
308
+ })
309
+ })
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // mergeFields prop does not cause errors
313
+ // ---------------------------------------------------------------------------
314
+
315
+ describe('BlockRenderer — mergeFields prop', () => {
316
+ it('renders without error when mergeFields is empty', () => {
317
+ expect(() => renderBlock(textBlock, false)).not.toThrow()
318
+ })
319
+
320
+ it('renders without error when mergeFields has items', () => {
321
+ expect(() =>
322
+ render(
323
+ <BlockRenderer
324
+ block={textBlock}
325
+ editing={false}
326
+ onChange={jest.fn()}
327
+ mergeFields={[{ key: '{{name}}', label: 'Name', example: 'John', category: 'contact' }]}
328
+ />
329
+ )
330
+ ).not.toThrow()
331
+ })
332
+ })