@startsimpli/ui 0.4.6 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/__mocks__/next/link.js +11 -0
- package/src/components/ActivityTimeline.tsx +173 -0
- package/src/components/LogActivityDialog.tsx +303 -0
- package/src/components/QuickLogButtons.tsx +32 -0
- package/src/components/account/__tests__/account.test.tsx +315 -0
- package/src/components/badge/StageBadge.tsx +31 -0
- package/src/components/badge/index.ts +3 -0
- package/src/components/command-palette/CommandGroup.tsx +23 -0
- package/src/components/command-palette/CommandPalette.tsx +327 -0
- package/src/components/command-palette/CommandResultItem.tsx +59 -0
- package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
- package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
- package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
- package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
- package/src/components/command-palette/command-palette-context.tsx +51 -0
- package/src/components/command-palette/index.ts +9 -0
- package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
- package/src/components/compose/__tests__/compose.test.tsx +656 -0
- package/src/components/compose/compose-header.tsx +72 -0
- package/src/components/compose/compose-loading.tsx +13 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/save-status-indicator.tsx +57 -0
- package/src/components/compose/send-confirmation-dialog.tsx +87 -0
- package/src/components/compose/subject-input.tsx +25 -0
- package/src/components/compose/useAutoSave.ts +93 -0
- package/src/components/dashboard/DashboardGrid.tsx +32 -0
- package/src/components/dashboard/DashboardSection.tsx +32 -0
- package/src/components/dashboard/MetricCard.tsx +129 -0
- package/src/components/dashboard/PeriodSelector.tsx +55 -0
- package/src/components/dashboard/PipelineFunnel.tsx +126 -0
- package/src/components/dashboard/SparklineTrend.tsx +102 -0
- package/src/components/dashboard/TopCampaigns.tsx +132 -0
- package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
- package/src/components/dashboard/index.ts +20 -0
- package/src/components/dialog/ConfirmDialog.tsx +72 -0
- package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
- package/src/components/dialog/index.ts +3 -0
- package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
- package/src/components/email-dialogs/index.ts +14 -0
- package/src/components/email-dialogs/merge-fields.tsx +196 -0
- package/src/components/email-dialogs/preview-dialog.tsx +194 -0
- package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
- package/src/components/email-dialogs/template-picker.tsx +225 -0
- package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
- package/src/components/email-editor/BlockRenderer.tsx +120 -0
- package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
- package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
- package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
- package/src/components/email-editor/add-block-menu.tsx +151 -0
- package/src/components/email-editor/block-toolbar.tsx +73 -0
- package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
- package/src/components/email-editor/blocks/button-block.tsx +44 -0
- package/src/components/email-editor/blocks/divider-block.tsx +43 -0
- package/src/components/email-editor/blocks/footer-block.tsx +39 -0
- package/src/components/email-editor/blocks/header-block.tsx +39 -0
- package/src/components/email-editor/blocks/image-block.tsx +61 -0
- package/src/components/email-editor/blocks/index.ts +9 -0
- package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
- package/src/components/email-editor/blocks/social-block.tsx +75 -0
- package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
- package/src/components/email-editor/blocks/text-block.tsx +75 -0
- package/src/components/email-editor/editor-sidebar.tsx +66 -0
- package/src/components/email-editor/email-editor.tsx +497 -0
- package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
- package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
- package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
- package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
- package/src/components/email-editor/index.ts +51 -0
- package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
- package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
- package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
- package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
- package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
- package/src/components/email-editor/panels/index.ts +3 -0
- package/src/components/email-editor/renderer/block-renderers.ts +209 -0
- package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
- package/src/components/email-editor/types.ts +413 -0
- package/src/components/email-editor/utils/defaults.ts +116 -0
- package/src/components/email-editor/utils/undo-redo.ts +59 -0
- package/src/components/enrichment/EnrichButton.tsx +33 -0
- package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
- package/src/components/enrichment/QualityBadge.tsx +43 -0
- package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
- package/src/components/enrichment/index.ts +8 -0
- package/src/components/gantt/GanttBoardView.tsx +71 -0
- package/src/components/gantt/GanttChart.tsx +140 -887
- package/src/components/gantt/GanttFilterBar.tsx +100 -0
- package/src/components/gantt/GanttListView.tsx +63 -0
- package/src/components/gantt/GanttTimelineView.tsx +215 -0
- package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
- package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
- package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
- package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
- package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
- package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
- package/src/components/gantt/hooks/useGanttState.ts +644 -0
- package/src/components/gantt/index.ts +10 -0
- package/src/components/gantt/types.ts +5 -5
- package/src/components/index.ts +46 -0
- package/src/components/integrations/ConnectionStatus.tsx +77 -0
- package/src/components/integrations/IntegrationCard.tsx +92 -0
- package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
- package/src/components/integrations/index.ts +5 -0
- package/src/components/kanban/KanbanBoard.tsx +103 -0
- package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
- package/src/components/kanban/index.ts +2 -0
- package/src/components/lists/CreateListDialog.tsx +158 -0
- package/src/components/lists/ListCard.tsx +77 -0
- package/src/components/lists/__tests__/lists.test.tsx +263 -0
- package/src/components/lists/index.ts +5 -0
- package/src/components/loading/__tests__/loading.test.tsx +114 -0
- package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
- package/src/components/pipeline/StageTransitionModal.tsx +146 -0
- package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
- package/src/components/pipeline/index.ts +2 -0
- package/src/components/settings/SettingsCard.tsx +33 -0
- package/src/components/settings/SettingsLayout.tsx +28 -0
- package/src/components/settings/SettingsNav.tsx +42 -0
- package/src/components/settings/__tests__/settings.test.tsx +181 -0
- package/src/components/settings/index.ts +6 -0
- package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
|
@@ -0,0 +1,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
|
+
}
|