@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,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
|
+
}
|