@startsimpli/ui 0.4.6 → 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.
Files changed (69) hide show
  1. package/package.json +2 -1
  2. package/src/components/ActivityTimeline.tsx +173 -0
  3. package/src/components/LogActivityDialog.tsx +303 -0
  4. package/src/components/QuickLogButtons.tsx +32 -0
  5. package/src/components/badge/StageBadge.tsx +31 -0
  6. package/src/components/badge/index.ts +3 -0
  7. package/src/components/command-palette/CommandPalette.tsx +344 -0
  8. package/src/components/command-palette/command-palette-context.tsx +51 -0
  9. package/src/components/command-palette/index.ts +3 -0
  10. package/src/components/compose/compose-header.tsx +72 -0
  11. package/src/components/compose/compose-loading.tsx +13 -0
  12. package/src/components/compose/index.ts +6 -0
  13. package/src/components/compose/save-status-indicator.tsx +57 -0
  14. package/src/components/compose/send-confirmation-dialog.tsx +87 -0
  15. package/src/components/compose/subject-input.tsx +25 -0
  16. package/src/components/compose/useAutoSave.ts +93 -0
  17. package/src/components/dashboard/DashboardGrid.tsx +32 -0
  18. package/src/components/dashboard/DashboardSection.tsx +32 -0
  19. package/src/components/dashboard/MetricCard.tsx +129 -0
  20. package/src/components/dashboard/PeriodSelector.tsx +55 -0
  21. package/src/components/dashboard/SparklineTrend.tsx +102 -0
  22. package/src/components/dashboard/index.ts +14 -0
  23. package/src/components/email-dialogs/index.ts +14 -0
  24. package/src/components/email-dialogs/merge-fields.tsx +196 -0
  25. package/src/components/email-dialogs/preview-dialog.tsx +194 -0
  26. package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
  27. package/src/components/email-dialogs/template-picker.tsx +225 -0
  28. package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
  29. package/src/components/email-editor/add-block-menu.tsx +151 -0
  30. package/src/components/email-editor/block-toolbar.tsx +73 -0
  31. package/src/components/email-editor/blocks/button-block.tsx +44 -0
  32. package/src/components/email-editor/blocks/divider-block.tsx +43 -0
  33. package/src/components/email-editor/blocks/footer-block.tsx +39 -0
  34. package/src/components/email-editor/blocks/header-block.tsx +39 -0
  35. package/src/components/email-editor/blocks/image-block.tsx +61 -0
  36. package/src/components/email-editor/blocks/index.ts +9 -0
  37. package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
  38. package/src/components/email-editor/blocks/social-block.tsx +75 -0
  39. package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
  40. package/src/components/email-editor/blocks/text-block.tsx +75 -0
  41. package/src/components/email-editor/editor-sidebar.tsx +791 -0
  42. package/src/components/email-editor/email-editor.tsx +886 -0
  43. package/src/components/email-editor/index.ts +50 -0
  44. package/src/components/email-editor/renderer/block-renderers.ts +209 -0
  45. package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
  46. package/src/components/email-editor/types.ts +413 -0
  47. package/src/components/email-editor/utils/defaults.ts +116 -0
  48. package/src/components/email-editor/utils/undo-redo.ts +59 -0
  49. package/src/components/enrichment/EnrichButton.tsx +33 -0
  50. package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
  51. package/src/components/enrichment/QualityBadge.tsx +43 -0
  52. package/src/components/enrichment/index.ts +8 -0
  53. package/src/components/gantt/GanttChart.tsx +25 -25
  54. package/src/components/gantt/types.ts +5 -5
  55. package/src/components/index.ts +46 -0
  56. package/src/components/integrations/ConnectionStatus.tsx +77 -0
  57. package/src/components/integrations/IntegrationCard.tsx +92 -0
  58. package/src/components/integrations/index.ts +5 -0
  59. package/src/components/kanban/KanbanBoard.tsx +103 -0
  60. package/src/components/kanban/index.ts +2 -0
  61. package/src/components/lists/CreateListDialog.tsx +158 -0
  62. package/src/components/lists/ListCard.tsx +77 -0
  63. package/src/components/lists/index.ts +5 -0
  64. package/src/components/pipeline/StageTransitionModal.tsx +146 -0
  65. package/src/components/pipeline/index.ts +2 -0
  66. package/src/components/settings/SettingsCard.tsx +33 -0
  67. package/src/components/settings/SettingsLayout.tsx +28 -0
  68. package/src/components/settings/SettingsNav.tsx +42 -0
  69. package/src/components/settings/index.ts +6 -0
@@ -0,0 +1,344 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react'
4
+ import { Search, ArrowRight } from 'lucide-react'
5
+ import { useCommandPalette } from './command-palette-context'
6
+
7
+ export interface SearchResult {
8
+ id: string
9
+ type: string
10
+ name: string
11
+ detail: string
12
+ path: string
13
+ }
14
+
15
+ export interface QuickAction {
16
+ id: string
17
+ icon: React.ElementType
18
+ name: string
19
+ shortcut?: string
20
+ action: () => void
21
+ }
22
+
23
+ export interface NavigationItem {
24
+ id: string
25
+ icon: React.ElementType
26
+ name: string
27
+ path: string
28
+ }
29
+
30
+ export interface CommandPaletteProps {
31
+ searchFn?: (query: string) => Promise<SearchResult[]>
32
+ quickActions?: QuickAction[]
33
+ navigationItems?: NavigationItem[]
34
+ recentItems?: SearchResult[]
35
+ typeIcons?: Record<string, React.ElementType>
36
+ typeColors?: Record<string, string>
37
+ placeholder?: string
38
+ onNavigate: (path: string) => void
39
+ }
40
+
41
+ export function CommandPalette({
42
+ searchFn,
43
+ quickActions = [],
44
+ navigationItems = [],
45
+ recentItems = [],
46
+ typeIcons = {},
47
+ typeColors = {},
48
+ placeholder = 'Search or type a command...',
49
+ onNavigate,
50
+ }: CommandPaletteProps) {
51
+ const { isOpen, close } = useCommandPalette()
52
+ const inputRef = useRef<HTMLInputElement>(null)
53
+ const [query, setQuery] = useState('')
54
+ const [selectedIndex, setSelectedIndex] = useState(0)
55
+ const [results, setResults] = useState<SearchResult[]>([])
56
+ const [isLoading, setIsLoading] = useState(false)
57
+
58
+ // Calculate all items for keyboard navigation
59
+ const filteredNav = query
60
+ ? navigationItems.filter(n => n.name.toLowerCase().includes(query.toLowerCase()))
61
+ : []
62
+
63
+ const allItems = [
64
+ ...results.map(r => ({ ...r, itemType: 'result' as const })),
65
+ ...quickActions.map(a => ({ ...a, itemType: 'action' as const })),
66
+ ...(!query ? recentItems.slice(0, 5).map(r => ({ ...r, itemType: 'recent' as const })) : []),
67
+ ...filteredNav.map(n => ({ ...n, itemType: 'nav' as const })),
68
+ ]
69
+
70
+ // Focus input when opened
71
+ useEffect(() => {
72
+ if (isOpen) {
73
+ setQuery('')
74
+ setSelectedIndex(0)
75
+ setResults([])
76
+ setTimeout(() => inputRef.current?.focus(), 0)
77
+ }
78
+ }, [isOpen])
79
+
80
+ // Search debounce
81
+ useEffect(() => {
82
+ if (!query.trim() || !searchFn) {
83
+ setResults([])
84
+ return
85
+ }
86
+
87
+ const timer = setTimeout(async () => {
88
+ setIsLoading(true)
89
+ try {
90
+ const data = await searchFn(query)
91
+ setResults(data)
92
+ } catch (error) {
93
+ console.error('Search error:', error)
94
+ setResults([])
95
+ } finally {
96
+ setIsLoading(false)
97
+ }
98
+ }, 200)
99
+
100
+ return () => clearTimeout(timer)
101
+ }, [query, searchFn])
102
+
103
+ // Keyboard navigation
104
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
105
+ if (e.key === 'ArrowDown') {
106
+ e.preventDefault()
107
+ setSelectedIndex(prev => Math.min(prev + 1, allItems.length - 1))
108
+ } else if (e.key === 'ArrowUp') {
109
+ e.preventDefault()
110
+ setSelectedIndex(prev => Math.max(prev - 1, 0))
111
+ } else if (e.key === 'Enter' && allItems[selectedIndex]) {
112
+ e.preventDefault()
113
+ const item = allItems[selectedIndex]
114
+ if (item.itemType === 'action') {
115
+ (item as unknown as QuickAction).action()
116
+ } else if ('path' in item) {
117
+ onNavigate(item.path)
118
+ }
119
+ close()
120
+ }
121
+ }, [allItems, selectedIndex, onNavigate, close])
122
+
123
+ // Reset selection when results change
124
+ useEffect(() => {
125
+ setSelectedIndex(0)
126
+ }, [results])
127
+
128
+ if (!isOpen) return null
129
+
130
+ const DefaultIcon = Search
131
+
132
+ return (
133
+ <div
134
+ className="fixed inset-0 z-50 flex items-start justify-center pt-20"
135
+ onClick={close}
136
+ >
137
+ {/* Backdrop */}
138
+ <div className="absolute inset-0 bg-black/50 backdrop-blur-sm pointer-events-none" />
139
+
140
+ {/* Palette */}
141
+ <div
142
+ role="dialog"
143
+ aria-modal="true"
144
+ aria-label="Command palette"
145
+ className="relative w-full max-w-xl bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden"
146
+ onClick={e => e.stopPropagation()}
147
+ >
148
+ {/* Search input */}
149
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-gray-200">
150
+ <Search className="w-5 h-5 text-gray-400" />
151
+ <input
152
+ ref={inputRef}
153
+ type="text"
154
+ value={query}
155
+ onChange={(e) => setQuery(e.target.value)}
156
+ onKeyDown={handleKeyDown}
157
+ placeholder={placeholder}
158
+ className="flex-1 outline-none text-lg bg-transparent"
159
+ />
160
+ <kbd className="px-2 py-1 bg-gray-100 text-gray-500 text-xs rounded">ESC</kbd>
161
+ </div>
162
+
163
+ {/* Results */}
164
+ <div role="listbox" aria-label="Search results" className="max-h-96 overflow-y-auto">
165
+ {/* Search Results */}
166
+ {query && results.length > 0 && (
167
+ <>
168
+ <div className="px-3 py-2">
169
+ <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
170
+ Results for &quot;{query}&quot;
171
+ </p>
172
+ </div>
173
+ {results.map((result, i) => {
174
+ const Icon = typeIcons[result.type] || DefaultIcon
175
+ const colorClass = typeColors[result.type] || 'bg-gray-100 text-gray-600'
176
+ const itemIndex = i
177
+
178
+ return (
179
+ <div
180
+ key={result.id}
181
+ role="option"
182
+ aria-selected={selectedIndex === itemIndex}
183
+ className={`flex items-center gap-3 px-4 py-3 cursor-pointer ${
184
+ selectedIndex === itemIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
185
+ }`}
186
+ onClick={() => {
187
+ onNavigate(result.path)
188
+ close()
189
+ }}
190
+ >
191
+ <div className={`w-8 h-8 rounded-full flex items-center justify-center ${colorClass}`}>
192
+ <Icon className="w-4 h-4" />
193
+ </div>
194
+ <div className="flex-1">
195
+ <p className="font-medium text-gray-900">{result.name}</p>
196
+ <p className="text-sm text-gray-500">{result.detail}</p>
197
+ </div>
198
+ <ArrowRight className="w-4 h-4 text-gray-400" />
199
+ </div>
200
+ )
201
+ })}
202
+ </>
203
+ )}
204
+
205
+ {/* Loading state */}
206
+ {isLoading && (
207
+ <div className="px-4 py-3 text-gray-500 text-sm">
208
+ Searching...
209
+ </div>
210
+ )}
211
+
212
+ {/* No results */}
213
+ {query && !isLoading && results.length === 0 && (
214
+ <div className="px-4 py-3 text-gray-500 text-sm">
215
+ No results found for &quot;{query}&quot;
216
+ </div>
217
+ )}
218
+
219
+ {/* Quick Actions */}
220
+ {quickActions.length > 0 && (
221
+ <>
222
+ <div className="px-3 py-2 border-t border-gray-100">
223
+ <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
224
+ Quick Actions
225
+ </p>
226
+ </div>
227
+ {quickActions.map((action, i) => {
228
+ const itemIndex = results.length + i
229
+ return (
230
+ <div
231
+ key={action.id}
232
+ role="option"
233
+ aria-selected={selectedIndex === itemIndex}
234
+ className={`flex items-center gap-3 px-4 py-3 cursor-pointer ${
235
+ selectedIndex === itemIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
236
+ }`}
237
+ onClick={() => {
238
+ action.action()
239
+ close()
240
+ }}
241
+ >
242
+ <div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
243
+ <action.icon className="w-4 h-4 text-gray-600" />
244
+ </div>
245
+ <p className="flex-1 font-medium text-gray-700">{action.name}</p>
246
+ {action.shortcut && (
247
+ <kbd className="px-2 py-1 bg-gray-100 text-gray-500 text-xs rounded font-mono">
248
+ {action.shortcut}
249
+ </kbd>
250
+ )}
251
+ </div>
252
+ )
253
+ })}
254
+ </>
255
+ )}
256
+
257
+ {/* Recent Items (when no query) */}
258
+ {!query && recentItems.length > 0 && (
259
+ <>
260
+ <div className="px-3 py-2 border-t border-gray-100">
261
+ <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
262
+ Recent
263
+ </p>
264
+ </div>
265
+ {recentItems.slice(0, 5).map((item, i) => {
266
+ const Icon = typeIcons[item.type] || DefaultIcon
267
+ const itemIndex = results.length + quickActions.length + i
268
+
269
+ return (
270
+ <div
271
+ key={item.id}
272
+ role="option"
273
+ aria-selected={selectedIndex === itemIndex}
274
+ className={`flex items-center gap-3 px-4 py-3 cursor-pointer ${
275
+ selectedIndex === itemIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
276
+ }`}
277
+ onClick={() => {
278
+ onNavigate(item.path)
279
+ close()
280
+ }}
281
+ >
282
+ <div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
283
+ <Icon className="w-4 h-4 text-gray-600" />
284
+ </div>
285
+ <div className="flex-1">
286
+ <p className="font-medium text-gray-700">{item.name}</p>
287
+ <p className="text-sm text-gray-500">{item.detail}</p>
288
+ </div>
289
+ <ArrowRight className="w-4 h-4 text-gray-400" />
290
+ </div>
291
+ )
292
+ })}
293
+ </>
294
+ )}
295
+
296
+ {/* Filtered Navigation (when query matches) */}
297
+ {filteredNav.length > 0 && (
298
+ <>
299
+ <div className="px-3 py-2 border-t border-gray-100">
300
+ <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
301
+ Go to
302
+ </p>
303
+ </div>
304
+ {filteredNav.map((navItem, i) => {
305
+ const itemIndex = results.length + quickActions.length + i
306
+
307
+ return (
308
+ <div
309
+ key={navItem.id}
310
+ role="option"
311
+ aria-selected={selectedIndex === itemIndex}
312
+ className={`flex items-center gap-3 px-4 py-3 cursor-pointer ${
313
+ selectedIndex === itemIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
314
+ }`}
315
+ onClick={() => {
316
+ onNavigate(navItem.path)
317
+ close()
318
+ }}
319
+ >
320
+ <div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
321
+ <navItem.icon className="w-4 h-4 text-gray-600" />
322
+ </div>
323
+ <p className="flex-1 font-medium text-gray-700">{navItem.name}</p>
324
+ <ArrowRight className="w-4 h-4 text-gray-400" />
325
+ </div>
326
+ )
327
+ })}
328
+ </>
329
+ )}
330
+ </div>
331
+
332
+ {/* Footer */}
333
+ <div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-t border-gray-200 text-xs text-gray-500">
334
+ <div className="flex items-center gap-4">
335
+ <span><kbd className="px-1.5 py-0.5 bg-white border rounded">&uarr;&darr;</kbd> Navigate</span>
336
+ <span><kbd className="px-1.5 py-0.5 bg-white border rounded">&crarr;</kbd> Select</span>
337
+ <span><kbd className="px-1.5 py-0.5 bg-white border rounded">ESC</kbd> Close</span>
338
+ </div>
339
+ <span>Press <kbd className="px-1.5 py-0.5 bg-white border rounded">&amp;#8984;K</kbd> anywhere</span>
340
+ </div>
341
+ </div>
342
+ </div>
343
+ )
344
+ }
@@ -0,0 +1,51 @@
1
+ 'use client'
2
+
3
+ import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
4
+
5
+ interface CommandPaletteContextType {
6
+ isOpen: boolean
7
+ open: () => void
8
+ close: () => void
9
+ toggle: () => void
10
+ }
11
+
12
+ const CommandPaletteContext = createContext<CommandPaletteContextType | undefined>(undefined)
13
+
14
+ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
15
+ const [isOpen, setIsOpen] = useState(false)
16
+
17
+ const open = useCallback(() => setIsOpen(true), [])
18
+ const close = useCallback(() => setIsOpen(false), [])
19
+ const toggle = useCallback(() => setIsOpen(prev => !prev), [])
20
+
21
+ // Global keyboard shortcut: CMD+K / Ctrl+K
22
+ useEffect(() => {
23
+ const handleKeyDown = (e: KeyboardEvent) => {
24
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
25
+ e.preventDefault()
26
+ toggle()
27
+ }
28
+ if (e.key === 'Escape' && isOpen) {
29
+ e.preventDefault()
30
+ close()
31
+ }
32
+ }
33
+
34
+ document.addEventListener('keydown', handleKeyDown)
35
+ return () => document.removeEventListener('keydown', handleKeyDown)
36
+ }, [isOpen, toggle, close])
37
+
38
+ return (
39
+ <CommandPaletteContext.Provider value={{ isOpen, open, close, toggle }}>
40
+ {children}
41
+ </CommandPaletteContext.Provider>
42
+ )
43
+ }
44
+
45
+ export function useCommandPalette() {
46
+ const context = useContext(CommandPaletteContext)
47
+ if (!context) {
48
+ throw new Error('useCommandPalette must be used within a CommandPaletteProvider')
49
+ }
50
+ return context
51
+ }
@@ -0,0 +1,3 @@
1
+ export { CommandPalette } from './CommandPalette'
2
+ export type { CommandPaletteProps, SearchResult, QuickAction, NavigationItem } from './CommandPalette'
3
+ export { CommandPaletteProvider, useCommandPalette } from './command-palette-context'
@@ -0,0 +1,72 @@
1
+ 'use client'
2
+
3
+ import { ReactNode } from 'react'
4
+ import { Button } from '../ui/button'
5
+ import { ArrowLeft, Save, Eye, Send } from 'lucide-react'
6
+ import { SaveStatusIndicator } from './save-status-indicator'
7
+
8
+ interface ComposeHeaderProps {
9
+ title: string
10
+ saving: boolean
11
+ lastSavedLabel: string | null
12
+ hasUnsavedChanges: boolean
13
+ canSend: boolean
14
+ onBack: () => void
15
+ onSave: () => void
16
+ onPreview: () => void
17
+ onSend: () => void
18
+ extraActions?: ReactNode
19
+ }
20
+
21
+ export function ComposeHeader({
22
+ title,
23
+ saving,
24
+ lastSavedLabel,
25
+ hasUnsavedChanges,
26
+ canSend,
27
+ onBack,
28
+ onSave,
29
+ onPreview,
30
+ onSend,
31
+ extraActions,
32
+ }: ComposeHeaderProps) {
33
+ return (
34
+ <div className="flex items-center justify-between px-4 py-2 border-b bg-background">
35
+ <div className="flex items-center gap-3 min-w-0">
36
+ <Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={onBack}>
37
+ <ArrowLeft className="h-4 w-4" />
38
+ </Button>
39
+ <span className="text-sm font-medium truncate">{title}</span>
40
+ <SaveStatusIndicator
41
+ saving={saving}
42
+ lastSavedLabel={lastSavedLabel}
43
+ hasUnsavedChanges={hasUnsavedChanges}
44
+ />
45
+ </div>
46
+ <div className="flex items-center gap-2 shrink-0">
47
+ <Button
48
+ variant="outline"
49
+ size="sm"
50
+ onClick={onSave}
51
+ disabled={saving}
52
+ >
53
+ <Save className="mr-1.5 h-3.5 w-3.5" />
54
+ Save
55
+ </Button>
56
+ <Button variant="outline" size="sm" onClick={onPreview}>
57
+ <Eye className="mr-1.5 h-3.5 w-3.5" />
58
+ Preview
59
+ </Button>
60
+ {extraActions}
61
+ <Button
62
+ size="sm"
63
+ disabled={!canSend}
64
+ onClick={onSend}
65
+ >
66
+ <Send className="mr-1.5 h-3.5 w-3.5" />
67
+ Send
68
+ </Button>
69
+ </div>
70
+ </div>
71
+ )
72
+ }
@@ -0,0 +1,13 @@
1
+ 'use client'
2
+
3
+ export function ComposeLoading() {
4
+ return (
5
+ <div className="p-6">
6
+ <div className="animate-pulse">
7
+ <div className="h-8 bg-gray-300 rounded w-1/4 mb-6"></div>
8
+ <div className="h-12 bg-gray-200 rounded mb-4"></div>
9
+ <div className="h-64 bg-gray-200 rounded"></div>
10
+ </div>
11
+ </div>
12
+ )
13
+ }
@@ -0,0 +1,6 @@
1
+ export { useAutoSave } from './useAutoSave'
2
+ export { SaveStatusIndicator } from './save-status-indicator'
3
+ export { ComposeHeader } from './compose-header'
4
+ export { SendConfirmationDialog } from './send-confirmation-dialog'
5
+ export { SubjectInput } from './subject-input'
6
+ export { ComposeLoading } from './compose-loading'
@@ -0,0 +1,57 @@
1
+ 'use client'
2
+
3
+ import { Loader2 } from 'lucide-react'
4
+ import {
5
+ Tooltip,
6
+ TooltipContent,
7
+ TooltipProvider,
8
+ TooltipTrigger,
9
+ } from '../ui/tooltip'
10
+
11
+ interface SaveStatusIndicatorProps {
12
+ saving: boolean
13
+ lastSavedLabel: string | null
14
+ hasUnsavedChanges: boolean
15
+ }
16
+
17
+ export function SaveStatusIndicator({
18
+ saving,
19
+ lastSavedLabel,
20
+ hasUnsavedChanges,
21
+ }: SaveStatusIndicatorProps) {
22
+ const label = saving
23
+ ? 'Saving...'
24
+ : lastSavedLabel
25
+ ? `Saved ${lastSavedLabel}`
26
+ : hasUnsavedChanges
27
+ ? 'Unsaved changes'
28
+ : 'New draft'
29
+
30
+ return (
31
+ <TooltipProvider delayDuration={200}>
32
+ <Tooltip>
33
+ <TooltipTrigger asChild>
34
+ <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground cursor-default">
35
+ {saving ? (
36
+ <Loader2 className="h-2.5 w-2.5 animate-spin" />
37
+ ) : (
38
+ <span
39
+ className={`inline-block h-2 w-2 rounded-full ${
40
+ hasUnsavedChanges
41
+ ? 'bg-amber-400'
42
+ : lastSavedLabel
43
+ ? 'bg-green-500'
44
+ : 'border border-muted-foreground'
45
+ }`}
46
+ />
47
+ )}
48
+ {label}
49
+ </span>
50
+ </TooltipTrigger>
51
+ <TooltipContent side="bottom">
52
+ <p className="text-xs">{label}</p>
53
+ </TooltipContent>
54
+ </Tooltip>
55
+ </TooltipProvider>
56
+ )
57
+ }
@@ -0,0 +1,87 @@
1
+ 'use client'
2
+
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from '../ui/dialog'
11
+ import { Button } from '../ui/button'
12
+ import { Send, Loader2, Clock } from 'lucide-react'
13
+
14
+ interface SendSummaryItem {
15
+ label: string
16
+ value: string
17
+ }
18
+
19
+ interface SendConfirmationDialogProps {
20
+ open: boolean
21
+ onOpenChange: (open: boolean) => void
22
+ onConfirm: () => void
23
+ sending: boolean
24
+ title?: string
25
+ description?: string
26
+ summaryItems: SendSummaryItem[]
27
+ warningMessage?: string
28
+ }
29
+
30
+ export function SendConfirmationDialog({
31
+ open,
32
+ onOpenChange,
33
+ onConfirm,
34
+ sending,
35
+ title = 'Send Update',
36
+ description = 'Are you ready to send this?',
37
+ summaryItems,
38
+ warningMessage = 'Emails will be sent immediately. Each recipient will receive a personalized version with their information.',
39
+ }: SendConfirmationDialogProps) {
40
+ return (
41
+ <Dialog open={open} onOpenChange={onOpenChange}>
42
+ <DialogContent>
43
+ <DialogHeader>
44
+ <DialogTitle>{title}</DialogTitle>
45
+ <DialogDescription>{description}</DialogDescription>
46
+ </DialogHeader>
47
+
48
+ <div className="space-y-4 py-4">
49
+ <div className="p-4 bg-muted rounded-lg space-y-2">
50
+ {summaryItems.map((item) => (
51
+ <div key={item.label} className="flex justify-between text-sm">
52
+ <span className="text-muted-foreground">{item.label}:</span>
53
+ <span className="font-medium">{item.value}</span>
54
+ </div>
55
+ ))}
56
+ </div>
57
+
58
+ {warningMessage && (
59
+ <div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
60
+ <Clock className="h-4 w-4 mt-0.5 flex-shrink-0" />
61
+ <p className="text-sm">{warningMessage}</p>
62
+ </div>
63
+ )}
64
+ </div>
65
+
66
+ <DialogFooter>
67
+ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>
68
+ Cancel
69
+ </Button>
70
+ <Button onClick={onConfirm} disabled={sending}>
71
+ {sending ? (
72
+ <>
73
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
74
+ Sending...
75
+ </>
76
+ ) : (
77
+ <>
78
+ <Send className="mr-2 h-4 w-4" />
79
+ Send Now
80
+ </>
81
+ )}
82
+ </Button>
83
+ </DialogFooter>
84
+ </DialogContent>
85
+ </Dialog>
86
+ )
87
+ }
@@ -0,0 +1,25 @@
1
+ 'use client'
2
+
3
+ import { Input } from '../ui/input'
4
+
5
+ interface SubjectInputProps {
6
+ value: string
7
+ onChange: (value: string) => void
8
+ placeholder?: string
9
+ }
10
+
11
+ export function SubjectInput({
12
+ value,
13
+ onChange,
14
+ placeholder = 'Subject line...',
15
+ }: SubjectInputProps) {
16
+ return (
17
+ <Input
18
+ id="subject"
19
+ value={value}
20
+ onChange={(e) => onChange(e.target.value)}
21
+ placeholder={placeholder}
22
+ className="border-0 shadow-none focus-visible:ring-0 text-lg font-medium px-0 h-auto py-0"
23
+ />
24
+ )
25
+ }