@startsimpli/ui 0.4.5 → 0.4.7
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/components/ActivityTimeline.tsx +173 -0
- package/src/components/LogActivityDialog.tsx +303 -0
- package/src/components/QuickLogButtons.tsx +32 -0
- package/src/components/badge/StageBadge.tsx +31 -0
- package/src/components/badge/index.ts +3 -0
- package/src/components/command-palette/CommandPalette.tsx +344 -0
- package/src/components/command-palette/command-palette-context.tsx +51 -0
- package/src/components/command-palette/index.ts +3 -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/SparklineTrend.tsx +102 -0
- package/src/components/dashboard/index.ts +14 -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/add-block-menu.tsx +151 -0
- package/src/components/email-editor/block-toolbar.tsx +73 -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 +791 -0
- package/src/components/email-editor/email-editor.tsx +886 -0
- package/src/components/email-editor/index.ts +50 -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/index.ts +8 -0
- package/src/components/gantt/GanttChart.tsx +25 -25
- 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/index.ts +5 -0
- package/src/components/kanban/KanbanBoard.tsx +103 -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/index.ts +5 -0
- package/src/components/pipeline/StageTransitionModal.tsx +146 -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/index.ts +6 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
} from '../ui/dialog'
|
|
10
|
+
import { Button } from '../ui/button'
|
|
11
|
+
import { Label } from '../ui/label'
|
|
12
|
+
import {
|
|
13
|
+
Select,
|
|
14
|
+
SelectContent,
|
|
15
|
+
SelectItem,
|
|
16
|
+
SelectTrigger,
|
|
17
|
+
SelectValue,
|
|
18
|
+
} from '../ui/select'
|
|
19
|
+
import { Tabs, TabsList, TabsTrigger } from '../ui/tabs'
|
|
20
|
+
import { Monitor, Smartphone, Code } from 'lucide-react'
|
|
21
|
+
import { MergeFieldPreview } from './merge-fields'
|
|
22
|
+
import { cn } from '../../utils/cn'
|
|
23
|
+
|
|
24
|
+
export interface PreviewRecipient {
|
|
25
|
+
id: string
|
|
26
|
+
name: string
|
|
27
|
+
label?: string
|
|
28
|
+
/** Sample data for merge field substitution, keyed by merge field token (e.g. "{{recipient.firstName}}") */
|
|
29
|
+
mergeData?: Record<string, string>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface PreviewDialogProps {
|
|
33
|
+
open: boolean
|
|
34
|
+
onOpenChange: (open: boolean) => void
|
|
35
|
+
subject: string
|
|
36
|
+
/**
|
|
37
|
+
* Pre-rendered email body HTML. The component will wrap this in an email
|
|
38
|
+
* shell with proper styling for preview.
|
|
39
|
+
*/
|
|
40
|
+
bodyHtml: string
|
|
41
|
+
/** Recipients available for merge field preview */
|
|
42
|
+
recipients?: PreviewRecipient[]
|
|
43
|
+
/** Additional merge field sample data applied on top of recipient data */
|
|
44
|
+
sampleData?: Record<string, string>
|
|
45
|
+
/** Unsubscribe link text. Set to null to hide. */
|
|
46
|
+
unsubscribeText?: string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Default sample recipient for preview
|
|
50
|
+
const DEFAULT_RECIPIENT: PreviewRecipient = {
|
|
51
|
+
id: 'sample',
|
|
52
|
+
name: 'John Smith',
|
|
53
|
+
mergeData: {
|
|
54
|
+
'{{recipient.firstName}}': 'John',
|
|
55
|
+
'{{recipient.lastName}}': 'Smith',
|
|
56
|
+
'{{recipient.fullName}}': 'John Smith',
|
|
57
|
+
'{{recipient.email}}': 'john@example.com',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function PreviewDialog({
|
|
62
|
+
open,
|
|
63
|
+
onOpenChange,
|
|
64
|
+
subject,
|
|
65
|
+
bodyHtml,
|
|
66
|
+
recipients = [],
|
|
67
|
+
sampleData,
|
|
68
|
+
unsubscribeText = 'Unsubscribe from these updates',
|
|
69
|
+
}: PreviewDialogProps) {
|
|
70
|
+
const [viewMode, setViewMode] = useState<'desktop' | 'mobile' | 'html'>('desktop')
|
|
71
|
+
const [selectedRecipientId, setSelectedRecipientId] = useState<string>('sample')
|
|
72
|
+
|
|
73
|
+
const previewRecipients = recipients.length > 0 ? recipients : [DEFAULT_RECIPIENT]
|
|
74
|
+
const selectedRecipient = previewRecipients.find(r => r.id === selectedRecipientId) || previewRecipients[0]
|
|
75
|
+
|
|
76
|
+
// Build merge data from selected recipient + any extra sample data
|
|
77
|
+
const mergeDataForPreview: Record<string, string> = {
|
|
78
|
+
...selectedRecipient.mergeData,
|
|
79
|
+
'{{date.today}}': new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
|
|
80
|
+
'{{date.month}}': new Date().toLocaleDateString('en-US', { month: 'long' }),
|
|
81
|
+
'{{date.quarter}}': `Q${Math.ceil((new Date().getMonth() + 1) / 3)} ${new Date().getFullYear()}`,
|
|
82
|
+
'{{date.year}}': new Date().getFullYear().toString(),
|
|
83
|
+
...sampleData,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const previewSubject = MergeFieldPreview({ content: subject, sampleData: mergeDataForPreview })
|
|
87
|
+
const previewBody = MergeFieldPreview({ content: bodyHtml, sampleData: mergeDataForPreview })
|
|
88
|
+
|
|
89
|
+
const fullEmailHtml = `
|
|
90
|
+
<!DOCTYPE html>
|
|
91
|
+
<html>
|
|
92
|
+
<head>
|
|
93
|
+
<meta charset="utf-8">
|
|
94
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
95
|
+
<title>${previewSubject}</title>
|
|
96
|
+
</head>
|
|
97
|
+
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f3f4f6;">
|
|
98
|
+
<div style="max-width: 600px; margin: 0 auto; padding: 24px;">
|
|
99
|
+
<div style="background: white; border-radius: 8px; padding: 32px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
|
100
|
+
${previewBody}
|
|
101
|
+
</div>
|
|
102
|
+
${unsubscribeText ? `
|
|
103
|
+
<div style="text-align: center; padding: 24px; font-size: 12px; color: #6b7280;">
|
|
104
|
+
<a href="#" style="color: #6b7280;">${unsubscribeText}</a>
|
|
105
|
+
</div>
|
|
106
|
+
` : ''}
|
|
107
|
+
</div>
|
|
108
|
+
</body>
|
|
109
|
+
</html>
|
|
110
|
+
`.trim()
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
114
|
+
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
|
115
|
+
<DialogHeader>
|
|
116
|
+
<DialogTitle>Preview Email</DialogTitle>
|
|
117
|
+
</DialogHeader>
|
|
118
|
+
|
|
119
|
+
<div className="flex items-center justify-between gap-4 pb-4 border-b">
|
|
120
|
+
<div className="flex items-center gap-4">
|
|
121
|
+
<div className="space-y-1">
|
|
122
|
+
<Label className="text-xs text-muted-foreground">Preview as</Label>
|
|
123
|
+
<Select value={selectedRecipientId} onValueChange={setSelectedRecipientId}>
|
|
124
|
+
<SelectTrigger className="w-[200px]">
|
|
125
|
+
<SelectValue />
|
|
126
|
+
</SelectTrigger>
|
|
127
|
+
<SelectContent>
|
|
128
|
+
{previewRecipients.map(r => (
|
|
129
|
+
<SelectItem key={r.id} value={r.id}>
|
|
130
|
+
{r.label || r.name}
|
|
131
|
+
</SelectItem>
|
|
132
|
+
))}
|
|
133
|
+
</SelectContent>
|
|
134
|
+
</Select>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as typeof viewMode)}>
|
|
139
|
+
<TabsList>
|
|
140
|
+
<TabsTrigger value="desktop" className="gap-2">
|
|
141
|
+
<Monitor className="h-4 w-4" />
|
|
142
|
+
Desktop
|
|
143
|
+
</TabsTrigger>
|
|
144
|
+
<TabsTrigger value="mobile" className="gap-2">
|
|
145
|
+
<Smartphone className="h-4 w-4" />
|
|
146
|
+
Mobile
|
|
147
|
+
</TabsTrigger>
|
|
148
|
+
<TabsTrigger value="html" className="gap-2">
|
|
149
|
+
<Code className="h-4 w-4" />
|
|
150
|
+
HTML
|
|
151
|
+
</TabsTrigger>
|
|
152
|
+
</TabsList>
|
|
153
|
+
</Tabs>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div className="flex-1 overflow-auto">
|
|
157
|
+
{/* Subject line preview */}
|
|
158
|
+
<div className="p-4 bg-muted/50 rounded-lg mb-4">
|
|
159
|
+
<Label className="text-xs text-muted-foreground">Subject</Label>
|
|
160
|
+
<p className="font-medium mt-1">{previewSubject}</p>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{viewMode === 'html' ? (
|
|
164
|
+
<div className="bg-muted rounded-lg p-4 overflow-auto">
|
|
165
|
+
<pre className="text-xs whitespace-pre-wrap font-mono">{fullEmailHtml}</pre>
|
|
166
|
+
</div>
|
|
167
|
+
) : (
|
|
168
|
+
<div
|
|
169
|
+
className={cn(
|
|
170
|
+
'bg-gray-100 rounded-lg p-4 mx-auto transition-all',
|
|
171
|
+
viewMode === 'mobile' ? 'max-w-[375px]' : 'max-w-full'
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
175
|
+
<iframe
|
|
176
|
+
srcDoc={fullEmailHtml}
|
|
177
|
+
className="w-full border-0"
|
|
178
|
+
style={{ height: viewMode === 'mobile' ? '500px' : '400px' }}
|
|
179
|
+
title="Email preview"
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<div className="flex justify-end gap-2 pt-4 border-t">
|
|
187
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
188
|
+
Close
|
|
189
|
+
</Button>
|
|
190
|
+
</div>
|
|
191
|
+
</DialogContent>
|
|
192
|
+
</Dialog>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
@@ -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
|
+
}
|