@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.
Files changed (122) hide show
  1. package/package.json +2 -1
  2. package/src/__mocks__/next/link.js +11 -0
  3. package/src/components/ActivityTimeline.tsx +173 -0
  4. package/src/components/LogActivityDialog.tsx +303 -0
  5. package/src/components/QuickLogButtons.tsx +32 -0
  6. package/src/components/account/__tests__/account.test.tsx +315 -0
  7. package/src/components/badge/StageBadge.tsx +31 -0
  8. package/src/components/badge/index.ts +3 -0
  9. package/src/components/command-palette/CommandGroup.tsx +23 -0
  10. package/src/components/command-palette/CommandPalette.tsx +327 -0
  11. package/src/components/command-palette/CommandResultItem.tsx +59 -0
  12. package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
  13. package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
  14. package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
  15. package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
  16. package/src/components/command-palette/command-palette-context.tsx +51 -0
  17. package/src/components/command-palette/index.ts +9 -0
  18. package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
  19. package/src/components/compose/__tests__/compose.test.tsx +656 -0
  20. package/src/components/compose/compose-header.tsx +72 -0
  21. package/src/components/compose/compose-loading.tsx +13 -0
  22. package/src/components/compose/index.ts +6 -0
  23. package/src/components/compose/save-status-indicator.tsx +57 -0
  24. package/src/components/compose/send-confirmation-dialog.tsx +87 -0
  25. package/src/components/compose/subject-input.tsx +25 -0
  26. package/src/components/compose/useAutoSave.ts +93 -0
  27. package/src/components/dashboard/DashboardGrid.tsx +32 -0
  28. package/src/components/dashboard/DashboardSection.tsx +32 -0
  29. package/src/components/dashboard/MetricCard.tsx +129 -0
  30. package/src/components/dashboard/PeriodSelector.tsx +55 -0
  31. package/src/components/dashboard/PipelineFunnel.tsx +126 -0
  32. package/src/components/dashboard/SparklineTrend.tsx +102 -0
  33. package/src/components/dashboard/TopCampaigns.tsx +132 -0
  34. package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
  35. package/src/components/dashboard/index.ts +20 -0
  36. package/src/components/dialog/ConfirmDialog.tsx +72 -0
  37. package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
  38. package/src/components/dialog/index.ts +3 -0
  39. package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
  40. package/src/components/email-dialogs/index.ts +14 -0
  41. package/src/components/email-dialogs/merge-fields.tsx +196 -0
  42. package/src/components/email-dialogs/preview-dialog.tsx +194 -0
  43. package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
  44. package/src/components/email-dialogs/template-picker.tsx +225 -0
  45. package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
  46. package/src/components/email-editor/BlockRenderer.tsx +120 -0
  47. package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
  48. package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
  49. package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
  50. package/src/components/email-editor/add-block-menu.tsx +151 -0
  51. package/src/components/email-editor/block-toolbar.tsx +73 -0
  52. package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
  53. package/src/components/email-editor/blocks/button-block.tsx +44 -0
  54. package/src/components/email-editor/blocks/divider-block.tsx +43 -0
  55. package/src/components/email-editor/blocks/footer-block.tsx +39 -0
  56. package/src/components/email-editor/blocks/header-block.tsx +39 -0
  57. package/src/components/email-editor/blocks/image-block.tsx +61 -0
  58. package/src/components/email-editor/blocks/index.ts +9 -0
  59. package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
  60. package/src/components/email-editor/blocks/social-block.tsx +75 -0
  61. package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
  62. package/src/components/email-editor/blocks/text-block.tsx +75 -0
  63. package/src/components/email-editor/editor-sidebar.tsx +66 -0
  64. package/src/components/email-editor/email-editor.tsx +497 -0
  65. package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
  66. package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
  67. package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
  68. package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
  69. package/src/components/email-editor/index.ts +51 -0
  70. package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
  71. package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
  72. package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
  73. package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
  74. package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
  75. package/src/components/email-editor/panels/index.ts +3 -0
  76. package/src/components/email-editor/renderer/block-renderers.ts +209 -0
  77. package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
  78. package/src/components/email-editor/types.ts +413 -0
  79. package/src/components/email-editor/utils/defaults.ts +116 -0
  80. package/src/components/email-editor/utils/undo-redo.ts +59 -0
  81. package/src/components/enrichment/EnrichButton.tsx +33 -0
  82. package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
  83. package/src/components/enrichment/QualityBadge.tsx +43 -0
  84. package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
  85. package/src/components/enrichment/index.ts +8 -0
  86. package/src/components/gantt/GanttBoardView.tsx +71 -0
  87. package/src/components/gantt/GanttChart.tsx +140 -887
  88. package/src/components/gantt/GanttFilterBar.tsx +100 -0
  89. package/src/components/gantt/GanttListView.tsx +63 -0
  90. package/src/components/gantt/GanttTimelineView.tsx +215 -0
  91. package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
  92. package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
  93. package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
  94. package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
  95. package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
  96. package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
  97. package/src/components/gantt/hooks/useGanttState.ts +644 -0
  98. package/src/components/gantt/index.ts +10 -0
  99. package/src/components/gantt/types.ts +5 -5
  100. package/src/components/index.ts +46 -0
  101. package/src/components/integrations/ConnectionStatus.tsx +77 -0
  102. package/src/components/integrations/IntegrationCard.tsx +92 -0
  103. package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
  104. package/src/components/integrations/index.ts +5 -0
  105. package/src/components/kanban/KanbanBoard.tsx +103 -0
  106. package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
  107. package/src/components/kanban/index.ts +2 -0
  108. package/src/components/lists/CreateListDialog.tsx +158 -0
  109. package/src/components/lists/ListCard.tsx +77 -0
  110. package/src/components/lists/__tests__/lists.test.tsx +263 -0
  111. package/src/components/lists/index.ts +5 -0
  112. package/src/components/loading/__tests__/loading.test.tsx +114 -0
  113. package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
  114. package/src/components/pipeline/StageTransitionModal.tsx +146 -0
  115. package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
  116. package/src/components/pipeline/index.ts +2 -0
  117. package/src/components/settings/SettingsCard.tsx +33 -0
  118. package/src/components/settings/SettingsLayout.tsx +28 -0
  119. package/src/components/settings/SettingsNav.tsx +42 -0
  120. package/src/components/settings/__tests__/settings.test.tsx +181 -0
  121. package/src/components/settings/index.ts +6 -0
  122. 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
+ }