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