@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.
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,93 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState, useCallback } from 'react'
4
+
5
+ interface AutoSaveConfig {
6
+ /** Serialized representation of current content for change comparison */
7
+ serializedContent: string
8
+ /** Function to call when auto-saving (receives isManual flag) */
9
+ onSave: (isManual: boolean) => Promise<void>
10
+ /** Auto-save delay in milliseconds (default: 30000) */
11
+ delay?: number
12
+ }
13
+
14
+ interface AutoSaveState {
15
+ saving: boolean
16
+ setSaving: (saving: boolean) => void
17
+ lastSaved: Date | null
18
+ setLastSaved: (date: Date | null) => void
19
+ hasUnsavedChanges: boolean
20
+ /** Update the saved-content snapshot (call after loading or saving a draft) */
21
+ snapshotContent: (serialized: string) => void
22
+ /** Format the last saved time as a human-readable string */
23
+ formatLastSaved: () => string | null
24
+ }
25
+
26
+ export function useAutoSave({
27
+ serializedContent,
28
+ onSave,
29
+ delay = 30000,
30
+ }: AutoSaveConfig): AutoSaveState {
31
+ const [saving, setSaving] = useState(false)
32
+ const [lastSaved, setLastSaved] = useState<Date | null>(null)
33
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
34
+
35
+ const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null)
36
+ const lastSavedContentRef = useRef<string>('')
37
+
38
+ const snapshotContent = useCallback((s: string) => {
39
+ lastSavedContentRef.current = s
40
+ }, [])
41
+
42
+ const formatLastSaved = useCallback(() => {
43
+ if (!lastSaved || isNaN(lastSaved.getTime())) return null
44
+ const diff = Date.now() - lastSaved.getTime()
45
+ if (diff < 60000) return 'just now'
46
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
47
+ return lastSaved.toLocaleTimeString()
48
+ }, [lastSaved])
49
+
50
+ // Track changes and trigger auto-save
51
+ useEffect(() => {
52
+ if (autoSaveTimerRef.current) {
53
+ clearTimeout(autoSaveTimerRef.current)
54
+ }
55
+
56
+ const hasChanges = serializedContent !== lastSavedContentRef.current
57
+ setHasUnsavedChanges(hasChanges)
58
+
59
+ if (hasChanges) {
60
+ autoSaveTimerRef.current = setTimeout(() => {
61
+ onSave(false)
62
+ }, delay)
63
+ }
64
+
65
+ return () => {
66
+ if (autoSaveTimerRef.current) {
67
+ clearTimeout(autoSaveTimerRef.current)
68
+ }
69
+ }
70
+ }, [serializedContent, onSave, delay])
71
+
72
+ // Warn on leave with unsaved changes
73
+ useEffect(() => {
74
+ const handleBeforeUnload = (e: BeforeUnloadEvent) => {
75
+ if (hasUnsavedChanges) {
76
+ e.preventDefault()
77
+ e.returnValue = ''
78
+ }
79
+ }
80
+ window.addEventListener('beforeunload', handleBeforeUnload)
81
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload)
82
+ }, [hasUnsavedChanges])
83
+
84
+ return {
85
+ saving,
86
+ setSaving,
87
+ lastSaved,
88
+ setLastSaved,
89
+ hasUnsavedChanges,
90
+ snapshotContent,
91
+ formatLastSaved,
92
+ }
93
+ }
@@ -0,0 +1,32 @@
1
+ import * as React from 'react'
2
+
3
+ export interface DashboardGridProps {
4
+ children: React.ReactNode
5
+ /** Number of columns at lg breakpoint */
6
+ columns?: 1 | 2 | 3 | 4
7
+ /** Gap size between items */
8
+ gap?: 'sm' | 'md' | 'lg'
9
+ className?: string
10
+ }
11
+
12
+ const gapMap = { sm: 'gap-4', md: 'gap-6', lg: 'gap-8' } as const
13
+
14
+ const colMap = {
15
+ 1: 'lg:grid-cols-1',
16
+ 2: 'lg:grid-cols-2',
17
+ 3: 'lg:grid-cols-3',
18
+ 4: 'sm:grid-cols-2 lg:grid-cols-4',
19
+ } as const
20
+
21
+ export function DashboardGrid({
22
+ children,
23
+ columns = 2,
24
+ gap = 'md',
25
+ className = '',
26
+ }: DashboardGridProps) {
27
+ return (
28
+ <div className={`grid grid-cols-1 ${colMap[columns]} ${gapMap[gap]} ${className}`}>
29
+ {children}
30
+ </div>
31
+ )
32
+ }
@@ -0,0 +1,32 @@
1
+ import * as React from 'react'
2
+
3
+ export interface DashboardSectionProps {
4
+ title: string
5
+ description?: string
6
+ action?: React.ReactNode
7
+ children: React.ReactNode
8
+ className?: string
9
+ }
10
+
11
+ export function DashboardSection({
12
+ title,
13
+ description,
14
+ action,
15
+ children,
16
+ className = '',
17
+ }: DashboardSectionProps) {
18
+ return (
19
+ <div className={`space-y-4 ${className}`}>
20
+ <div className="flex items-start justify-between">
21
+ <div>
22
+ <h2 className="text-lg font-semibold text-gray-900">{title}</h2>
23
+ {description && (
24
+ <p className="text-sm text-gray-600 mt-1">{description}</p>
25
+ )}
26
+ </div>
27
+ {action && <div className="flex-shrink-0">{action}</div>}
28
+ </div>
29
+ {children}
30
+ </div>
31
+ )
32
+ }
@@ -0,0 +1,129 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+ import { SparklineTrend } from './SparklineTrend'
4
+
5
+ export interface MetricCardTrend {
6
+ value: number
7
+ isPositive?: boolean
8
+ label?: string
9
+ }
10
+
11
+ export interface MetricCardProps {
12
+ label: string
13
+ value: string | number
14
+ icon: React.ComponentType<{ className?: string }>
15
+ iconColor?: string
16
+ trend?: MetricCardTrend
17
+ sparklineData?: number[]
18
+ href?: string
19
+ isLoading?: boolean
20
+ error?: boolean
21
+ className?: string
22
+ }
23
+
24
+ export function MetricCard({
25
+ label,
26
+ value,
27
+ icon: Icon,
28
+ iconColor = 'text-blue-500',
29
+ trend,
30
+ sparklineData,
31
+ href,
32
+ isLoading,
33
+ error,
34
+ className,
35
+ }: MetricCardProps) {
36
+ const content = (
37
+ <div
38
+ className={cn(
39
+ 'rounded-xl border bg-card text-card-foreground shadow p-6 transition-all duration-200 hover:shadow-lg cursor-pointer',
40
+ error && 'border-red-200 bg-red-50',
41
+ isLoading && 'animate-pulse',
42
+ className,
43
+ )}
44
+ >
45
+ <div className="space-y-3">
46
+ {/* Header: Label and Icon */}
47
+ <div className="flex items-center justify-between">
48
+ <h3 className="text-sm font-medium text-gray-500">{label}</h3>
49
+ <div className={iconColor}>
50
+ <Icon className="w-5 h-5" aria-hidden="true" />
51
+ </div>
52
+ </div>
53
+
54
+ {/* Value */}
55
+ <div className="flex items-baseline justify-between">
56
+ <p className="text-3xl font-bold text-gray-900">
57
+ {isLoading ? '\u2014' : error ? 'Error' : value}
58
+ </p>
59
+
60
+ {/* Trend Indicator */}
61
+ {trend && !isLoading && !error && (
62
+ <TrendIndicator trend={trend} />
63
+ )}
64
+ </div>
65
+
66
+ {/* Trend Label */}
67
+ {trend?.label && !isLoading && !error && (
68
+ <p className="text-xs text-gray-500">{trend.label}</p>
69
+ )}
70
+
71
+ {/* Sparkline */}
72
+ {sparklineData && sparklineData.length > 1 && !isLoading && !error && (
73
+ <div className="pt-2">
74
+ <SparklineTrend data={sparklineData} color={iconColor} />
75
+ </div>
76
+ )}
77
+ </div>
78
+ </div>
79
+ )
80
+
81
+ if (href) {
82
+ // Render a plain <a> tag so the component works in any React framework
83
+ // without requiring a specific router. Consumers using Next.js can wrap
84
+ // with their own <Link> or pass an href and rely on client-side navigation.
85
+ return (
86
+ <a
87
+ href={href}
88
+ className="block focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded-lg"
89
+ aria-label={`View ${label} details`}
90
+ >
91
+ {content}
92
+ </a>
93
+ )
94
+ }
95
+
96
+ return content
97
+ }
98
+
99
+ function TrendIndicator({ trend }: { trend: MetricCardTrend }) {
100
+ const colorClass =
101
+ trend.isPositive === true
102
+ ? 'text-green-600'
103
+ : trend.isPositive === false
104
+ ? 'text-red-600'
105
+ : 'text-gray-500'
106
+
107
+ return (
108
+ <div className={`flex items-center gap-1 text-xs font-medium ${colorClass}`}>
109
+ {trend.isPositive === true && (
110
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
111
+ <path strokeLinecap="round" strokeLinejoin="round" d="M2 17L12 7l10 10" />
112
+ </svg>
113
+ )}
114
+ {trend.isPositive === false && (
115
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
116
+ <path strokeLinecap="round" strokeLinejoin="round" d="M2 7l10 10L22 7" />
117
+ </svg>
118
+ )}
119
+ {trend.isPositive === undefined && (
120
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
121
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14" />
122
+ </svg>
123
+ )}
124
+ <span>
125
+ {trend.value > 0 ? '+' : ''}{trend.value}%
126
+ </span>
127
+ </div>
128
+ )
129
+ }
@@ -0,0 +1,55 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+
4
+ export interface PeriodOption {
5
+ value: string
6
+ label: string
7
+ }
8
+
9
+ export interface PeriodSelectorProps {
10
+ periods?: PeriodOption[]
11
+ selected: string
12
+ onChange: (value: string) => void
13
+ className?: string
14
+ }
15
+
16
+ const DEFAULT_PERIODS: PeriodOption[] = [
17
+ { value: 'week', label: 'Week' },
18
+ { value: 'month', label: 'Month' },
19
+ { value: 'quarter', label: 'Quarter' },
20
+ { value: 'year', label: 'Year' },
21
+ ]
22
+
23
+ export function PeriodSelector({
24
+ periods = DEFAULT_PERIODS,
25
+ selected,
26
+ onChange,
27
+ className,
28
+ }: PeriodSelectorProps) {
29
+ return (
30
+ <div
31
+ className={cn(
32
+ 'flex items-center gap-1 bg-white rounded-lg border border-gray-200 p-1',
33
+ className,
34
+ )}
35
+ role="group"
36
+ aria-label="Time period"
37
+ >
38
+ {periods.map(({ value, label }) => (
39
+ <button
40
+ key={value}
41
+ onClick={() => onChange(value)}
42
+ className={cn(
43
+ 'px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
44
+ selected === value
45
+ ? 'bg-primary-100 text-primary-700'
46
+ : 'text-gray-600 hover:bg-gray-100',
47
+ )}
48
+ aria-pressed={selected === value}
49
+ >
50
+ {label}
51
+ </button>
52
+ ))}
53
+ </div>
54
+ )
55
+ }
@@ -0,0 +1,102 @@
1
+ import * as React from 'react'
2
+
3
+ export interface SparklineTrendProps {
4
+ data: number[]
5
+ color?: string
6
+ height?: number
7
+ className?: string
8
+ }
9
+
10
+ // Map common Tailwind color class suffixes to hex values
11
+ const COLOR_MAP: Record<string, string> = {
12
+ 'blue-500': '#3B82F6',
13
+ 'green-500': '#10B981',
14
+ 'purple-500': '#A855F7',
15
+ 'emerald-500': '#10B981',
16
+ 'red-500': '#EF4444',
17
+ 'orange-500': '#F97316',
18
+ 'yellow-500': '#EAB308',
19
+ 'indigo-500': '#6366F1',
20
+ 'pink-500': '#EC4899',
21
+ 'primary-500': '#3B82F6',
22
+ 'primary-600': '#2563EB',
23
+ 'accent-500': '#A855F7',
24
+ 'success-500': '#10B981',
25
+ }
26
+
27
+ function resolveColor(color: string): string {
28
+ // If it looks like a hex/rgb value already, use it directly
29
+ if (color.startsWith('#') || color.startsWith('rgb')) return color
30
+
31
+ // Strip common Tailwind prefixes like "text-"
32
+ const key = color.replace(/^text-/, '')
33
+ return COLOR_MAP[key] || COLOR_MAP['blue-500']
34
+ }
35
+
36
+ export function SparklineTrend({
37
+ data,
38
+ color = 'blue-500',
39
+ height = 40,
40
+ className,
41
+ }: SparklineTrendProps) {
42
+ if (data.length < 2) return null
43
+
44
+ const width = 100
45
+ const padding = 2
46
+
47
+ const min = Math.min(...data)
48
+ const max = Math.max(...data)
49
+ const range = max - min || 1
50
+
51
+ const points = data.map((value, index) => {
52
+ const x = (index / (data.length - 1)) * width
53
+ const y = height - ((value - min) / range) * (height - padding * 2) - padding
54
+ return `${x},${y}`
55
+ })
56
+
57
+ const pathData = `M ${points.join(' L ')}`
58
+ const areaData = `${pathData} L ${width},${height} L 0,${height} Z`
59
+
60
+ const hexColor = resolveColor(color)
61
+ const gradientId = `sparkline-gradient-${React.useId().replace(/:/g, '')}`
62
+
63
+ const trend =
64
+ data[data.length - 1] > data[0]
65
+ ? 'increasing'
66
+ : data[data.length - 1] < data[0]
67
+ ? 'decreasing'
68
+ : 'stable'
69
+
70
+ return (
71
+ <svg
72
+ width="100%"
73
+ height={height}
74
+ viewBox={`0 0 ${width} ${height}`}
75
+ preserveAspectRatio="none"
76
+ className={className ?? 'w-full'}
77
+ role="img"
78
+ aria-label={`Trend chart showing ${trend} pattern`}
79
+ >
80
+ <defs>
81
+ <linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
82
+ <stop offset="0%" stopColor={hexColor} stopOpacity="0.3" />
83
+ <stop offset="100%" stopColor={hexColor} stopOpacity="0.05" />
84
+ </linearGradient>
85
+ </defs>
86
+ <path
87
+ d={areaData}
88
+ fill={`url(#${gradientId})`}
89
+ className="transition-all duration-300"
90
+ />
91
+ <path
92
+ d={pathData}
93
+ fill="none"
94
+ stroke={hexColor}
95
+ strokeWidth="1.5"
96
+ strokeLinecap="round"
97
+ strokeLinejoin="round"
98
+ className="transition-all duration-300"
99
+ />
100
+ </svg>
101
+ )
102
+ }
@@ -0,0 +1,14 @@
1
+ export { MetricCard } from './MetricCard'
2
+ export type { MetricCardProps, MetricCardTrend } from './MetricCard'
3
+
4
+ export { PeriodSelector } from './PeriodSelector'
5
+ export type { PeriodSelectorProps, PeriodOption } from './PeriodSelector'
6
+
7
+ export { SparklineTrend } from './SparklineTrend'
8
+ export type { SparklineTrendProps } from './SparklineTrend'
9
+
10
+ export { DashboardGrid } from './DashboardGrid'
11
+ export type { DashboardGridProps } from './DashboardGrid'
12
+
13
+ export { DashboardSection } from './DashboardSection'
14
+ export type { DashboardSectionProps } from './DashboardSection'
@@ -0,0 +1,14 @@
1
+ export { ScheduleDialog } from './schedule-dialog'
2
+ export { TestSendDialog } from './test-send-dialog'
3
+ export { PreviewDialog } from './preview-dialog'
4
+ export type { PreviewRecipient } from './preview-dialog'
5
+ export { TemplatePicker } from './template-picker'
6
+ export type { EmailTemplate } from './template-picker'
7
+ export {
8
+ MergeFieldsMenu,
9
+ MergeFieldPreview,
10
+ replaceMergeFields,
11
+ DEFAULT_MERGE_FIELDS,
12
+ DEFAULT_CATEGORIES,
13
+ } from './merge-fields'
14
+ export type { MergeField, MergeFieldCategory } from './merge-fields'
@@ -0,0 +1,196 @@
1
+ 'use client'
2
+
3
+ import { Button } from '../ui/button'
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuLabel,
9
+ DropdownMenuSeparator,
10
+ DropdownMenuTrigger,
11
+ } from '../ui/dropdown-menu'
12
+ import { Braces } from 'lucide-react'
13
+
14
+ export interface MergeField {
15
+ key: string
16
+ label: string
17
+ example: string
18
+ category: string
19
+ }
20
+
21
+ export interface MergeFieldCategory {
22
+ name: string
23
+ label: string
24
+ }
25
+
26
+ /** Default merge field categories for email composition */
27
+ export const DEFAULT_CATEGORIES: MergeFieldCategory[] = [
28
+ { name: 'recipient', label: 'Recipient' },
29
+ { name: 'organization', label: 'Organization' },
30
+ { name: 'sender', label: 'Sender' },
31
+ { name: 'date', label: 'Date' },
32
+ ]
33
+
34
+ /** Default merge fields for email composition */
35
+ export const DEFAULT_MERGE_FIELDS: MergeField[] = [
36
+ // Recipient fields
37
+ { key: '{{recipient.firstName}}', label: 'First Name', example: 'John', category: 'recipient' },
38
+ { key: '{{recipient.lastName}}', label: 'Last Name', example: 'Smith', category: 'recipient' },
39
+ { key: '{{recipient.fullName}}', label: 'Full Name', example: 'John Smith', category: 'recipient' },
40
+ { key: '{{recipient.email}}', label: 'Email', example: 'john@example.com', category: 'recipient' },
41
+ { key: '{{recipient.title}}', label: 'Title', example: 'Partner', category: 'recipient' },
42
+
43
+ // Organization fields
44
+ { key: '{{organization.name}}', label: 'Organization', example: 'Acme Corp', category: 'organization' },
45
+ { key: '{{organization.type}}', label: 'Type', example: 'Enterprise', category: 'organization' },
46
+
47
+ // Sender fields
48
+ { key: '{{sender.name}}', label: 'Your Company', example: 'My Company', category: 'sender' },
49
+ { key: '{{sender.contactName}}', label: 'Your Name', example: 'Jane Doe', category: 'sender' },
50
+
51
+ // Date fields
52
+ { key: '{{date.today}}', label: 'Today', example: 'December 8, 2024', category: 'date' },
53
+ { key: '{{date.month}}', label: 'Current Month', example: 'December', category: 'date' },
54
+ { key: '{{date.quarter}}', label: 'Current Quarter', example: 'Q4 2024', category: 'date' },
55
+ { key: '{{date.year}}', label: 'Current Year', example: '2024', category: 'date' },
56
+ ]
57
+
58
+ interface MergeFieldsMenuProps {
59
+ onInsert: (field: string) => void
60
+ variant?: 'default' | 'compact'
61
+ /** Custom merge fields to display. Defaults to DEFAULT_MERGE_FIELDS. */
62
+ fields?: MergeField[]
63
+ /** Custom categories for grouping. Defaults to DEFAULT_CATEGORIES. */
64
+ categories?: MergeFieldCategory[]
65
+ }
66
+
67
+ export function MergeFieldsMenu({
68
+ onInsert,
69
+ variant = 'default',
70
+ fields = DEFAULT_MERGE_FIELDS,
71
+ categories = DEFAULT_CATEGORIES,
72
+ }: MergeFieldsMenuProps) {
73
+ const fieldsByCategory = categories.map((cat) => ({
74
+ ...cat,
75
+ fields: fields.filter((f) => f.category === cat.name),
76
+ }))
77
+
78
+ return (
79
+ <DropdownMenu>
80
+ <DropdownMenuTrigger asChild>
81
+ {variant === 'compact' ? (
82
+ <Button variant="ghost" size="sm" className="h-8 gap-1">
83
+ <Braces className="h-4 w-4" />
84
+ <span className="text-xs">Merge</span>
85
+ </Button>
86
+ ) : (
87
+ <Button variant="outline" size="sm">
88
+ <Braces className="mr-2 h-4 w-4" />
89
+ Insert Merge Field
90
+ </Button>
91
+ )}
92
+ </DropdownMenuTrigger>
93
+ <DropdownMenuContent align="start" className="w-64">
94
+ {fieldsByCategory.map((cat, idx) => (
95
+ <div key={cat.name}>
96
+ {idx > 0 && <DropdownMenuSeparator />}
97
+ <DropdownMenuLabel>{cat.label}</DropdownMenuLabel>
98
+ {cat.fields.map((field) => (
99
+ <MergeFieldItem key={field.key} field={field} onInsert={onInsert} />
100
+ ))}
101
+ </div>
102
+ ))}
103
+ </DropdownMenuContent>
104
+ </DropdownMenu>
105
+ )
106
+ }
107
+
108
+ function MergeFieldItem({ field, onInsert }: { field: MergeField; onInsert: (field: string) => void }) {
109
+ return (
110
+ <DropdownMenuItem onClick={() => onInsert(field.key)} className="flex justify-between">
111
+ <span>{field.label}</span>
112
+ <span className="text-xs text-muted-foreground">{field.example}</span>
113
+ </DropdownMenuItem>
114
+ )
115
+ }
116
+
117
+ // Preview component to show merge fields as visual tokens
118
+ interface MergeFieldPreviewProps {
119
+ content: string
120
+ sampleData?: Record<string, string>
121
+ }
122
+
123
+ /** Replace merge field tokens in content with sample data for previewing */
124
+ export function MergeFieldPreview({ content, sampleData }: MergeFieldPreviewProps) {
125
+ const defaultSampleData: Record<string, string> = {
126
+ '{{recipient.firstName}}': 'John',
127
+ '{{recipient.lastName}}': 'Smith',
128
+ '{{recipient.fullName}}': 'John Smith',
129
+ '{{recipient.email}}': 'john@example.com',
130
+ '{{recipient.title}}': 'Partner',
131
+ '{{organization.name}}': 'Acme Corp',
132
+ '{{organization.type}}': 'Enterprise',
133
+ '{{sender.name}}': 'My Company',
134
+ '{{sender.contactName}}': 'Jane Doe',
135
+ '{{date.today}}': new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
136
+ '{{date.month}}': new Date().toLocaleDateString('en-US', { month: 'long' }),
137
+ '{{date.quarter}}': `Q${Math.ceil((new Date().getMonth() + 1) / 3)} ${new Date().getFullYear()}`,
138
+ '{{date.year}}': new Date().getFullYear().toString(),
139
+ }
140
+
141
+ const data = { ...defaultSampleData, ...sampleData }
142
+
143
+ let preview = content
144
+ for (const [key, value] of Object.entries(data)) {
145
+ preview = preview.replace(new RegExp(key.replace(/[{}]/g, '\\$&'), 'g'), value)
146
+ }
147
+
148
+ return preview
149
+ }
150
+
151
+ /** Replace merge fields in content with actual recipient data for sending */
152
+ export function replaceMergeFields(
153
+ content: string,
154
+ recipient: {
155
+ firstName?: string | null
156
+ lastName?: string | null
157
+ name?: string | null
158
+ email?: string | null
159
+ title?: string | null
160
+ },
161
+ organization?: {
162
+ name?: string | null
163
+ type?: string | null
164
+ },
165
+ sender?: {
166
+ name?: string | null
167
+ contactName?: string | null
168
+ }
169
+ ): string {
170
+ const firstName = recipient.firstName || recipient.name?.split(' ')[0] || ''
171
+ const lastName = recipient.lastName || recipient.name?.split(' ').slice(1).join(' ') || ''
172
+ const fullName = recipient.name || `${firstName} ${lastName}`.trim()
173
+
174
+ const replacements: Record<string, string> = {
175
+ '{{recipient.firstName}}': firstName,
176
+ '{{recipient.lastName}}': lastName,
177
+ '{{recipient.fullName}}': fullName,
178
+ '{{recipient.email}}': recipient.email || '',
179
+ '{{recipient.title}}': recipient.title || '',
180
+ '{{organization.name}}': organization?.name || '',
181
+ '{{organization.type}}': organization?.type || '',
182
+ '{{sender.name}}': sender?.name || '',
183
+ '{{sender.contactName}}': sender?.contactName || '',
184
+ '{{date.today}}': new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
185
+ '{{date.month}}': new Date().toLocaleDateString('en-US', { month: 'long' }),
186
+ '{{date.quarter}}': `Q${Math.ceil((new Date().getMonth() + 1) / 3)} ${new Date().getFullYear()}`,
187
+ '{{date.year}}': new Date().getFullYear().toString(),
188
+ }
189
+
190
+ let result = content
191
+ for (const [key, value] of Object.entries(replacements)) {
192
+ result = result.replace(new RegExp(key.replace(/[{}]/g, '\\$&'), 'g'), value)
193
+ }
194
+
195
+ return result
196
+ }