@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,297 @@
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 { Label } from '../ui/label'
14
+ import { Calendar } from '../ui/calendar'
15
+ import {
16
+ Popover,
17
+ PopoverContent,
18
+ PopoverTrigger,
19
+ } from '../ui/popover'
20
+ import {
21
+ Select,
22
+ SelectContent,
23
+ SelectItem,
24
+ SelectTrigger,
25
+ SelectValue,
26
+ } from '../ui/select'
27
+ import { Loader2, CalendarIcon, Clock } from 'lucide-react'
28
+ import { useToast } from '../toast'
29
+ import { format, addDays, setHours, setMinutes, isBefore, startOfDay } from 'date-fns'
30
+ import { cn } from '../../utils/cn'
31
+
32
+ interface SummaryItem {
33
+ label: string
34
+ value: string
35
+ }
36
+
37
+ interface ScheduleDialogProps {
38
+ open: boolean
39
+ onOpenChange: (open: boolean) => void
40
+ /** Summary items displayed at the top of the dialog (e.g. subject, recipient count) */
41
+ summaryItems: SummaryItem[]
42
+ /** Called to schedule the message. Receives the ISO date string for the scheduled time. */
43
+ onSchedule: (scheduledAt: string) => Promise<void>
44
+ /** Called to save draft before scheduling when there are unsaved changes */
45
+ onSaveDraft?: () => Promise<void>
46
+ /** Called to prepare recipients (e.g. expand contact tags) before scheduling */
47
+ onPrepareRecipients?: () => Promise<void>
48
+ /** Whether there are unsaved changes that need saving before scheduling */
49
+ hasUnsavedChanges?: boolean
50
+ /** Dialog title. Defaults to "Schedule Send" */
51
+ title?: string
52
+ /** Dialog description. Defaults to "Choose when to send this email" */
53
+ description?: string
54
+ /** Info message shown after confirming schedule time */
55
+ scheduledInfoMessage?: string
56
+ }
57
+
58
+ // Generate time options in 30-minute increments
59
+ const generateTimeOptions = () => {
60
+ const options: { value: string; label: string }[] = []
61
+ for (let hour = 0; hour < 24; hour++) {
62
+ for (const minute of [0, 30]) {
63
+ const h = hour.toString().padStart(2, '0')
64
+ const m = minute.toString().padStart(2, '0')
65
+ const period = hour < 12 ? 'AM' : 'PM'
66
+ const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
67
+ options.push({
68
+ value: `${h}:${m}`,
69
+ label: `${displayHour}:${m.padStart(2, '0')} ${period}`,
70
+ })
71
+ }
72
+ }
73
+ return options
74
+ }
75
+
76
+ const TIME_OPTIONS = generateTimeOptions()
77
+
78
+ // Quick schedule options
79
+ const QUICK_OPTIONS = [
80
+ { label: 'Tomorrow 9 AM', getValue: () => setHours(setMinutes(addDays(new Date(), 1), 0), 9) },
81
+ { label: 'Tomorrow 2 PM', getValue: () => setHours(setMinutes(addDays(new Date(), 1), 0), 14) },
82
+ { label: 'Monday 9 AM', getValue: () => {
83
+ const today = new Date()
84
+ const daysUntilMonday = (8 - today.getDay()) % 7 || 7
85
+ return setHours(setMinutes(addDays(today, daysUntilMonday), 0), 9)
86
+ }},
87
+ { label: 'Next week', getValue: () => setHours(setMinutes(addDays(new Date(), 7), 0), 9) },
88
+ ]
89
+
90
+ export function ScheduleDialog({
91
+ open,
92
+ onOpenChange,
93
+ summaryItems,
94
+ onSchedule,
95
+ onSaveDraft,
96
+ onPrepareRecipients,
97
+ hasUnsavedChanges = false,
98
+ title = 'Schedule Send',
99
+ description = 'Choose when to send this email',
100
+ scheduledInfoMessage = 'You can cancel or edit this scheduled send from the messages list.',
101
+ }: ScheduleDialogProps) {
102
+ const { toast } = useToast()
103
+ const [scheduling, setScheduling] = useState(false)
104
+ const [selectedDate, setSelectedDate] = useState<Date | undefined>(addDays(new Date(), 1))
105
+ const [selectedTime, setSelectedTime] = useState('09:00')
106
+
107
+ const getScheduledDateTime = (): Date | null => {
108
+ if (!selectedDate) return null
109
+ const [hours, minutes] = selectedTime.split(':').map(Number)
110
+ return setMinutes(setHours(selectedDate, hours), minutes)
111
+ }
112
+
113
+ const isValidScheduleTime = (): boolean => {
114
+ const scheduledTime = getScheduledDateTime()
115
+ if (!scheduledTime) return false
116
+ // Must be at least 5 minutes in the future
117
+ return scheduledTime.getTime() > Date.now() + 5 * 60 * 1000
118
+ }
119
+
120
+ const handleQuickSelect = (getValue: () => Date) => {
121
+ const date = getValue()
122
+ setSelectedDate(startOfDay(date))
123
+ const hours = date.getHours().toString().padStart(2, '0')
124
+ const minutes = date.getMinutes().toString().padStart(2, '0')
125
+ setSelectedTime(`${hours}:${minutes}`)
126
+ }
127
+
128
+ const handleSchedule = async () => {
129
+ const scheduledDateTime = getScheduledDateTime()
130
+ if (!scheduledDateTime) {
131
+ toast({ description: 'Please select a date and time', variant: 'destructive' })
132
+ return
133
+ }
134
+
135
+ if (!isValidScheduleTime()) {
136
+ toast({ description: 'Scheduled time must be at least 5 minutes in the future', variant: 'destructive' })
137
+ return
138
+ }
139
+
140
+ setScheduling(true)
141
+ try {
142
+ // Save any pending changes first
143
+ if (hasUnsavedChanges && onSaveDraft) {
144
+ await onSaveDraft()
145
+ }
146
+
147
+ // Prepare recipients if needed
148
+ if (onPrepareRecipients) {
149
+ await onPrepareRecipients()
150
+ }
151
+
152
+ // Delegate scheduling to the caller
153
+ await onSchedule(scheduledDateTime.toISOString())
154
+
155
+ toast({
156
+ description: `Scheduled for ${format(scheduledDateTime, 'PPP')} at ${format(scheduledDateTime, 'p')}`,
157
+ })
158
+ onOpenChange(false)
159
+ } catch (error) {
160
+ console.error('Error scheduling:', error)
161
+ toast({
162
+ description: error instanceof Error ? error.message : 'Failed to schedule',
163
+ variant: 'destructive',
164
+ })
165
+ } finally {
166
+ setScheduling(false)
167
+ }
168
+ }
169
+
170
+ return (
171
+ <Dialog open={open} onOpenChange={onOpenChange}>
172
+ <DialogContent className="sm:max-w-[500px]">
173
+ <DialogHeader>
174
+ <DialogTitle>{title}</DialogTitle>
175
+ <DialogDescription>{description}</DialogDescription>
176
+ </DialogHeader>
177
+
178
+ <div className="space-y-6 py-4">
179
+ {/* Summary */}
180
+ <div className="p-4 bg-muted rounded-lg space-y-2">
181
+ {summaryItems.map((item) => (
182
+ <div key={item.label} className="flex justify-between text-sm">
183
+ <span className="text-muted-foreground">{item.label}:</span>
184
+ <span className="font-medium truncate ml-4">{item.value}</span>
185
+ </div>
186
+ ))}
187
+ </div>
188
+
189
+ {/* Quick options */}
190
+ <div className="space-y-2">
191
+ <Label className="text-sm text-muted-foreground">Quick schedule</Label>
192
+ <div className="grid grid-cols-2 gap-2">
193
+ {QUICK_OPTIONS.map((option) => (
194
+ <Button
195
+ key={option.label}
196
+ variant="outline"
197
+ size="sm"
198
+ onClick={() => handleQuickSelect(option.getValue)}
199
+ className="justify-start"
200
+ >
201
+ {option.label}
202
+ </Button>
203
+ ))}
204
+ </div>
205
+ </div>
206
+
207
+ {/* Date/Time picker */}
208
+ <div className="space-y-4">
209
+ <Label className="text-sm text-muted-foreground">Or pick a specific date and time</Label>
210
+
211
+ <div className="flex gap-4">
212
+ {/* Date picker */}
213
+ <div className="flex-1 space-y-2">
214
+ <Label htmlFor="date">Date</Label>
215
+ <Popover>
216
+ <PopoverTrigger asChild>
217
+ <Button
218
+ variant="outline"
219
+ className={cn(
220
+ 'w-full justify-start text-left font-normal',
221
+ !selectedDate && 'text-muted-foreground'
222
+ )}
223
+ >
224
+ <CalendarIcon className="mr-2 h-4 w-4" />
225
+ {selectedDate ? format(selectedDate, 'PPP') : 'Pick a date'}
226
+ </Button>
227
+ </PopoverTrigger>
228
+ <PopoverContent className="w-auto p-0" align="start">
229
+ <Calendar
230
+ mode="single"
231
+ selected={selectedDate}
232
+ onSelect={setSelectedDate}
233
+ disabled={(date) => isBefore(date, startOfDay(new Date()))}
234
+ initialFocus
235
+ />
236
+ </PopoverContent>
237
+ </Popover>
238
+ </div>
239
+
240
+ {/* Time picker */}
241
+ <div className="w-[140px] space-y-2">
242
+ <Label htmlFor="time">Time</Label>
243
+ <Select value={selectedTime} onValueChange={setSelectedTime}>
244
+ <SelectTrigger>
245
+ <Clock className="mr-2 h-4 w-4" />
246
+ <SelectValue />
247
+ </SelectTrigger>
248
+ <SelectContent className="max-h-[300px]">
249
+ {TIME_OPTIONS.map((option) => (
250
+ <SelectItem key={option.value} value={option.value}>
251
+ {option.label}
252
+ </SelectItem>
253
+ ))}
254
+ </SelectContent>
255
+ </Select>
256
+ </div>
257
+ </div>
258
+ </div>
259
+
260
+ {/* Preview scheduled time */}
261
+ {selectedDate && isValidScheduleTime() && (
262
+ <div className="p-3 bg-blue-50 border border-blue-200 rounded-lg text-blue-800 text-sm">
263
+ <p>
264
+ <strong>Scheduled for:</strong>{' '}
265
+ {format(getScheduledDateTime()!, 'PPPP')} at {format(getScheduledDateTime()!, 'p')}
266
+ </p>
267
+ {scheduledInfoMessage && (
268
+ <p className="text-xs mt-1 text-blue-600">
269
+ {scheduledInfoMessage}
270
+ </p>
271
+ )}
272
+ </div>
273
+ )}
274
+ </div>
275
+
276
+ <DialogFooter>
277
+ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={scheduling}>
278
+ Cancel
279
+ </Button>
280
+ <Button onClick={handleSchedule} disabled={scheduling || !isValidScheduleTime()}>
281
+ {scheduling ? (
282
+ <>
283
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
284
+ Scheduling...
285
+ </>
286
+ ) : (
287
+ <>
288
+ <CalendarIcon className="mr-2 h-4 w-4" />
289
+ Schedule Send
290
+ </>
291
+ )}
292
+ </Button>
293
+ </DialogFooter>
294
+ </DialogContent>
295
+ </Dialog>
296
+ )
297
+ }
@@ -0,0 +1,225 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useMemo, useCallback } from 'react'
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from '../ui/dialog'
11
+ import { Button } from '../ui/button'
12
+ import { Badge } from '../ui/badge'
13
+ import { ScrollArea } from '../ui/scroll-area'
14
+ import { Input } from '../ui/input'
15
+ import { FileText, Loader2, Search } from 'lucide-react'
16
+
17
+ export interface EmailTemplate {
18
+ id: string
19
+ name: string
20
+ description: string | null
21
+ subject: string
22
+ blocks: string | null
23
+ category: string | null
24
+ isDefault: boolean
25
+ useCount: number
26
+ /** Alternative field name for subject */
27
+ subjectTemplate?: string
28
+ /** Alternative field name for body */
29
+ bodyTemplate?: string
30
+ }
31
+
32
+ interface TemplatePickerProps {
33
+ open: boolean
34
+ onOpenChange: (open: boolean) => void
35
+ onSelectTemplate: (template: EmailTemplate) => void
36
+ /** Fetch templates from the API. Should return an array of templates. */
37
+ onLoadTemplates: () => Promise<EmailTemplate[]>
38
+ /** Optional callback to seed/create default templates when none exist */
39
+ onSeedTemplates?: () => Promise<void>
40
+ /** Optional category filter -- when set, only templates matching this category are shown */
41
+ categoryFilter?: string
42
+ }
43
+
44
+ // Normalize API response to ensure subject/blocks fields are populated
45
+ function normalizeTemplates(raw: EmailTemplate[]): EmailTemplate[] {
46
+ return raw.map((t) => ({
47
+ ...t,
48
+ subject: t.subject || t.subjectTemplate || '',
49
+ blocks: t.blocks || t.bodyTemplate || null,
50
+ }))
51
+ }
52
+
53
+ export function TemplatePicker({
54
+ open,
55
+ onOpenChange,
56
+ onSelectTemplate,
57
+ onLoadTemplates,
58
+ onSeedTemplates,
59
+ categoryFilter,
60
+ }: TemplatePickerProps) {
61
+ const [templates, setTemplates] = useState<EmailTemplate[]>([])
62
+ const [loading, setLoading] = useState(true)
63
+ const [error, setError] = useState<string | null>(null)
64
+ const [search, setSearch] = useState('')
65
+
66
+ const fetchTemplates = useCallback(async () => {
67
+ try {
68
+ setLoading(true)
69
+ setError(null)
70
+
71
+ const data = await onLoadTemplates()
72
+
73
+ // If no templates and a seed function is provided, try seeding
74
+ if ((!data || data.length === 0) && onSeedTemplates) {
75
+ await onSeedTemplates()
76
+ const retryData = await onLoadTemplates()
77
+ setTemplates(normalizeTemplates(retryData || []))
78
+ return
79
+ }
80
+
81
+ setTemplates(normalizeTemplates(data || []))
82
+ } catch (err) {
83
+ console.error('Error fetching templates:', err)
84
+ setError('Failed to load templates')
85
+ } finally {
86
+ setLoading(false)
87
+ }
88
+ }, [onLoadTemplates, onSeedTemplates])
89
+
90
+ useEffect(() => {
91
+ if (open) {
92
+ fetchTemplates()
93
+ setSearch('')
94
+ }
95
+ }, [open, fetchTemplates])
96
+
97
+ const filteredTemplates = useMemo(() => {
98
+ let result = templates
99
+ if (categoryFilter) {
100
+ result = result.filter((t) => t.category === categoryFilter)
101
+ }
102
+ if (search.trim()) {
103
+ const q = search.toLowerCase()
104
+ result = result.filter(
105
+ (t) =>
106
+ t.name.toLowerCase().includes(q) ||
107
+ (t.description && t.description.toLowerCase().includes(q)) ||
108
+ t.subject.toLowerCase().includes(q)
109
+ )
110
+ }
111
+ return result
112
+ }, [templates, categoryFilter, search])
113
+
114
+ const handleSelect = useCallback((template: EmailTemplate) => {
115
+ onSelectTemplate(template)
116
+ onOpenChange(false)
117
+ }, [onSelectTemplate, onOpenChange])
118
+
119
+ return (
120
+ <Dialog open={open} onOpenChange={onOpenChange}>
121
+ <DialogContent className="sm:max-w-[600px]">
122
+ <DialogHeader>
123
+ <DialogTitle>Choose a Template</DialogTitle>
124
+ <DialogDescription>
125
+ Start with a pre-built template to save time
126
+ </DialogDescription>
127
+ </DialogHeader>
128
+
129
+ {loading ? (
130
+ <div className="flex items-center justify-center py-8" role="status" aria-label="Loading templates">
131
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" aria-hidden="true" />
132
+ <span className="ml-2 text-muted-foreground">Loading templates...</span>
133
+ </div>
134
+ ) : error ? (
135
+ <div className="text-center py-8">
136
+ <p className="text-red-600">{error}</p>
137
+ <Button onClick={fetchTemplates} variant="outline" className="mt-2">
138
+ Retry
139
+ </Button>
140
+ </div>
141
+ ) : templates.length === 0 ? (
142
+ <div className="text-center py-8">
143
+ <FileText className="h-12 w-12 mx-auto text-muted-foreground mb-2" />
144
+ <p className="text-muted-foreground">No templates available</p>
145
+ {onSeedTemplates && (
146
+ <Button
147
+ onClick={async () => {
148
+ await onSeedTemplates()
149
+ fetchTemplates()
150
+ }}
151
+ variant="outline"
152
+ className="mt-2"
153
+ >
154
+ Create Default Templates
155
+ </Button>
156
+ )}
157
+ </div>
158
+ ) : (
159
+ <>
160
+ {/* Search */}
161
+ <div className="relative">
162
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
163
+ <Input
164
+ placeholder="Search templates..."
165
+ value={search}
166
+ onChange={(e) => setSearch(e.target.value)}
167
+ className="pl-9"
168
+ />
169
+ </div>
170
+
171
+ <ScrollArea className="h-[350px] pr-4" role="region" aria-label="Template list">
172
+ <div className="space-y-3" role="listbox" aria-label="Available templates">
173
+ {filteredTemplates.map((template) => (
174
+ <button
175
+ key={template.id}
176
+ onClick={() => handleSelect(template)}
177
+ className="w-full text-left p-4 rounded-lg border hover:border-primary hover:bg-accent/50 transition-colors"
178
+ role="option"
179
+ aria-label={`Select ${template.name} template`}
180
+ >
181
+ <div className="flex items-start justify-between">
182
+ <div className="flex items-center gap-2">
183
+ <FileText className="h-4 w-4 text-muted-foreground" />
184
+ <span className="font-medium">{template.name}</span>
185
+ </div>
186
+ <div className="flex items-center gap-2">
187
+ {template.isDefault && (
188
+ <Badge variant="secondary" className="text-xs">
189
+ Default
190
+ </Badge>
191
+ )}
192
+ </div>
193
+ </div>
194
+ {template.description && (
195
+ <p className="text-sm text-muted-foreground mt-1">
196
+ {template.description}
197
+ </p>
198
+ )}
199
+ <p className="text-sm text-muted-foreground mt-2 font-mono">
200
+ Subject: {template.subject}
201
+ </p>
202
+ </button>
203
+ ))}
204
+ {filteredTemplates.length === 0 && (
205
+ <p className="text-center py-6 text-muted-foreground">
206
+ No templates match your search
207
+ </p>
208
+ )}
209
+ </div>
210
+ </ScrollArea>
211
+
212
+ <div className="flex justify-between items-center pt-4 border-t">
213
+ <p className="text-xs text-muted-foreground">
214
+ {filteredTemplates.length} template{filteredTemplates.length !== 1 ? 's' : ''} available
215
+ </p>
216
+ <Button variant="ghost" onClick={() => onOpenChange(false)}>
217
+ Cancel
218
+ </Button>
219
+ </div>
220
+ </>
221
+ )}
222
+ </DialogContent>
223
+ </Dialog>
224
+ )
225
+ }