@papernote/ui 1.11.6 → 1.13.0
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/dist/components/AdminModal.d.ts.map +1 -1
- package/dist/components/ChatUI.d.ts +59 -0
- package/dist/components/ChatUI.d.ts.map +1 -0
- package/dist/components/InsightsPanelUI.d.ts +56 -0
- package/dist/components/InsightsPanelUI.d.ts.map +1 -0
- package/dist/components/Sidebar.d.ts.map +1 -1
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.d.ts +114 -2
- package/dist/index.esm.js +156 -8
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +156 -6
- package/dist/index.js.map +1 -1
- package/dist/styles.css +63 -0
- package/package.json +1 -1
- package/src/components/AdminModal.tsx +1 -0
- package/src/components/ChatUI.tsx +214 -0
- package/src/components/InsightsPanelUI.tsx +244 -0
- package/src/components/Sidebar.tsx +19 -1
- package/src/components/index.ts +6 -0
package/dist/styles.css
CHANGED
|
@@ -2005,6 +2005,10 @@ input:checked + .slider:before{
|
|
|
2005
2005
|
width: 50%;
|
|
2006
2006
|
}
|
|
2007
2007
|
|
|
2008
|
+
.w-1\/3{
|
|
2009
|
+
width: 33.333333%;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2008
2012
|
.w-1\/4{
|
|
2009
2013
|
width: 25%;
|
|
2010
2014
|
}
|
|
@@ -2077,6 +2081,10 @@ input:checked + .slider:before{
|
|
|
2077
2081
|
width: 1rem;
|
|
2078
2082
|
}
|
|
2079
2083
|
|
|
2084
|
+
.w-4\/5{
|
|
2085
|
+
width: 80%;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2080
2088
|
.w-4\/6{
|
|
2081
2089
|
width: 66.666667%;
|
|
2082
2090
|
}
|
|
@@ -2262,6 +2270,10 @@ input:checked + .slider:before{
|
|
|
2262
2270
|
max-width: 200px;
|
|
2263
2271
|
}
|
|
2264
2272
|
|
|
2273
|
+
.max-w-\[75\%\]{
|
|
2274
|
+
max-width: 75%;
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2265
2277
|
.max-w-\[calc\(100\%-0\.25rem\)\]{
|
|
2266
2278
|
max-width: calc(100% - 0.25rem);
|
|
2267
2279
|
}
|
|
@@ -3176,6 +3188,14 @@ input:checked + .slider:before{
|
|
|
3176
3188
|
border-top-right-radius: 0.5rem;
|
|
3177
3189
|
}
|
|
3178
3190
|
|
|
3191
|
+
.rounded-bl-sm{
|
|
3192
|
+
border-bottom-left-radius: 0.125rem;
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
.rounded-br-sm{
|
|
3196
|
+
border-bottom-right-radius: 0.125rem;
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3179
3199
|
.border{
|
|
3180
3200
|
border-width: 1px;
|
|
3181
3201
|
}
|
|
@@ -4406,6 +4426,10 @@ input:checked + .slider:before{
|
|
|
4406
4426
|
padding-bottom: 0px;
|
|
4407
4427
|
}
|
|
4408
4428
|
|
|
4429
|
+
.pb-1{
|
|
4430
|
+
padding-bottom: 0.25rem;
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4409
4433
|
.pb-12{
|
|
4410
4434
|
padding-bottom: 3rem;
|
|
4411
4435
|
}
|
|
@@ -4715,6 +4739,10 @@ input:checked + .slider:before{
|
|
|
4715
4739
|
letter-spacing: 0.05em;
|
|
4716
4740
|
}
|
|
4717
4741
|
|
|
4742
|
+
.tracking-widest{
|
|
4743
|
+
letter-spacing: 0.1em;
|
|
4744
|
+
}
|
|
4745
|
+
|
|
4718
4746
|
.text-accent-200{
|
|
4719
4747
|
--tw-text-opacity: 1;
|
|
4720
4748
|
color: rgb(232 231 224 / var(--tw-text-opacity, 1));
|
|
@@ -4750,6 +4778,11 @@ input:checked + .slider:before{
|
|
|
4750
4778
|
color: rgb(42 41 34 / var(--tw-text-opacity, 1));
|
|
4751
4779
|
}
|
|
4752
4780
|
|
|
4781
|
+
.text-amber-600{
|
|
4782
|
+
--tw-text-opacity: 1;
|
|
4783
|
+
color: rgb(217 119 6 / var(--tw-text-opacity, 1));
|
|
4784
|
+
}
|
|
4785
|
+
|
|
4753
4786
|
.text-blue-500{
|
|
4754
4787
|
--tw-text-opacity: 1;
|
|
4755
4788
|
color: rgb(59 130 246 / var(--tw-text-opacity, 1));
|
|
@@ -4889,6 +4922,11 @@ input:checked + .slider:before{
|
|
|
4889
4922
|
color: rgb(231 229 228 / var(--tw-text-opacity, 1));
|
|
4890
4923
|
}
|
|
4891
4924
|
|
|
4925
|
+
.text-primary-200{
|
|
4926
|
+
--tw-text-opacity: 1;
|
|
4927
|
+
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
|
|
4928
|
+
}
|
|
4929
|
+
|
|
4892
4930
|
.text-primary-500{
|
|
4893
4931
|
--tw-text-opacity: 1;
|
|
4894
4932
|
color: rgb(100 116 139 / var(--tw-text-opacity, 1));
|
|
@@ -4914,6 +4952,11 @@ input:checked + .slider:before{
|
|
|
4914
4952
|
color: rgb(15 23 42 / var(--tw-text-opacity, 1));
|
|
4915
4953
|
}
|
|
4916
4954
|
|
|
4955
|
+
.text-purple-600{
|
|
4956
|
+
--tw-text-opacity: 1;
|
|
4957
|
+
color: rgb(147 51 234 / var(--tw-text-opacity, 1));
|
|
4958
|
+
}
|
|
4959
|
+
|
|
4917
4960
|
.text-purple-700{
|
|
4918
4961
|
--tw-text-opacity: 1;
|
|
4919
4962
|
color: rgb(126 34 206 / var(--tw-text-opacity, 1));
|
|
@@ -5701,6 +5744,16 @@ input:checked + .slider:before{
|
|
|
5701
5744
|
}
|
|
5702
5745
|
}
|
|
5703
5746
|
|
|
5747
|
+
.placeholder\:text-ink-400::-moz-placeholder{
|
|
5748
|
+
--tw-text-opacity: 1;
|
|
5749
|
+
color: rgb(168 162 158 / var(--tw-text-opacity, 1));
|
|
5750
|
+
}
|
|
5751
|
+
|
|
5752
|
+
.placeholder\:text-ink-400::placeholder{
|
|
5753
|
+
--tw-text-opacity: 1;
|
|
5754
|
+
color: rgb(168 162 158 / var(--tw-text-opacity, 1));
|
|
5755
|
+
}
|
|
5756
|
+
|
|
5704
5757
|
.first\:rounded-t-lg:first-child{
|
|
5705
5758
|
border-top-left-radius: 0.5rem;
|
|
5706
5759
|
border-top-right-radius: 0.5rem;
|
|
@@ -6279,6 +6332,11 @@ input:checked + .slider:before{
|
|
|
6279
6332
|
border-color: rgb(239 68 68 / var(--tw-border-opacity, 1));
|
|
6280
6333
|
}
|
|
6281
6334
|
|
|
6335
|
+
.focus\:border-primary-300:focus{
|
|
6336
|
+
--tw-border-opacity: 1;
|
|
6337
|
+
border-color: rgb(203 213 225 / var(--tw-border-opacity, 1));
|
|
6338
|
+
}
|
|
6339
|
+
|
|
6282
6340
|
.focus\:border-primary-500:focus{
|
|
6283
6341
|
--tw-border-opacity: 1;
|
|
6284
6342
|
border-color: rgb(100 116 139 / var(--tw-border-opacity, 1));
|
|
@@ -6375,6 +6433,11 @@ input:checked + .slider:before{
|
|
|
6375
6433
|
--tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity, 1));
|
|
6376
6434
|
}
|
|
6377
6435
|
|
|
6436
|
+
.focus\:ring-primary-300:focus{
|
|
6437
|
+
--tw-ring-opacity: 1;
|
|
6438
|
+
--tw-ring-color: rgb(203 213 225 / var(--tw-ring-opacity, 1));
|
|
6439
|
+
}
|
|
6440
|
+
|
|
6378
6441
|
.focus\:ring-primary-500:focus{
|
|
6379
6442
|
--tw-ring-opacity: 1;
|
|
6380
6443
|
--tw-ring-color: rgb(100 116 139 / var(--tw-ring-opacity, 1));
|
package/package.json
CHANGED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import React, { forwardRef } from 'react';
|
|
2
|
+
import { Send, Bot, User } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
/** A single message in the chat thread */
|
|
5
|
+
export interface ChatMessage {
|
|
6
|
+
/** Unique message identifier */
|
|
7
|
+
id: string;
|
|
8
|
+
/** Who sent the message */
|
|
9
|
+
role: 'user' | 'assistant';
|
|
10
|
+
/** Message content */
|
|
11
|
+
content: string;
|
|
12
|
+
/** When the message was sent */
|
|
13
|
+
timestamp: Date;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ChatUIProps {
|
|
17
|
+
/** Message thread to display */
|
|
18
|
+
messages: ChatMessage[];
|
|
19
|
+
/** Controlled input value */
|
|
20
|
+
inputValue: string;
|
|
21
|
+
/** Show typing indicator */
|
|
22
|
+
isLoading?: boolean;
|
|
23
|
+
/** Input placeholder text */
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
/** Clickable suggested question chips */
|
|
26
|
+
suggestedQuestions?: string[];
|
|
27
|
+
/** Input change handler */
|
|
28
|
+
onInputChange: (value: string) => void;
|
|
29
|
+
/** Send message handler */
|
|
30
|
+
onSend: () => void;
|
|
31
|
+
/** Suggested question click handler */
|
|
32
|
+
onSuggestedQuestionClick?: (question: string) => void;
|
|
33
|
+
/** Ref for auto-scrolling to bottom of messages */
|
|
34
|
+
messagesEndRef?: React.RefObject<HTMLDivElement | null>;
|
|
35
|
+
/** Container height (CSS value) */
|
|
36
|
+
height?: string;
|
|
37
|
+
/** Additional CSS classes */
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatRelativeTime(date: Date): string {
|
|
42
|
+
const now = new Date();
|
|
43
|
+
const diffMs = now.getTime() - date.getTime();
|
|
44
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
45
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
46
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
47
|
+
|
|
48
|
+
if (diffMins < 1) return 'Just now';
|
|
49
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
50
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
51
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
52
|
+
|
|
53
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* ChatUI - Conversational chat interface component.
|
|
58
|
+
*
|
|
59
|
+
* Renders a scrollable message thread with user/assistant bubbles,
|
|
60
|
+
* a typing indicator, an input bar with send button (Enter to send,
|
|
61
|
+
* Shift+Enter for newline), and optional suggested question chips.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```tsx
|
|
65
|
+
* <ChatUI
|
|
66
|
+
* messages={messages}
|
|
67
|
+
* inputValue={input}
|
|
68
|
+
* isLoading={loading}
|
|
69
|
+
* onInputChange={setInput}
|
|
70
|
+
* onSend={handleSend}
|
|
71
|
+
* suggestedQuestions={['What are my top deals?']}
|
|
72
|
+
* onSuggestedQuestionClick={handleQuestion}
|
|
73
|
+
* />
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
const ChatUI = forwardRef<HTMLDivElement, ChatUIProps>(({
|
|
77
|
+
messages,
|
|
78
|
+
inputValue,
|
|
79
|
+
isLoading = false,
|
|
80
|
+
placeholder = 'Ask a question...',
|
|
81
|
+
suggestedQuestions = [],
|
|
82
|
+
onInputChange,
|
|
83
|
+
onSend,
|
|
84
|
+
onSuggestedQuestionClick,
|
|
85
|
+
messagesEndRef,
|
|
86
|
+
height = '600px',
|
|
87
|
+
className = '',
|
|
88
|
+
}, ref) => {
|
|
89
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
90
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
if (inputValue.trim()) {
|
|
93
|
+
onSend();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
ref={ref}
|
|
101
|
+
className={`flex flex-col bg-white rounded-lg border border-paper-200 overflow-hidden ${className}`}
|
|
102
|
+
style={{ height }}
|
|
103
|
+
>
|
|
104
|
+
{/* Messages area */}
|
|
105
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
106
|
+
{messages.length === 0 && !isLoading && (
|
|
107
|
+
<div className="flex flex-col items-center justify-center h-full text-center">
|
|
108
|
+
<Bot className="w-10 h-10 text-ink-300 mb-3" />
|
|
109
|
+
<p className="text-ink-500 text-sm">No messages yet. Start a conversation!</p>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{messages.map((message) => (
|
|
114
|
+
<div
|
|
115
|
+
key={message.id}
|
|
116
|
+
className={`flex gap-3 ${message.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}
|
|
117
|
+
>
|
|
118
|
+
{/* Avatar */}
|
|
119
|
+
<div
|
|
120
|
+
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
|
121
|
+
message.role === 'user'
|
|
122
|
+
? 'bg-primary-100 text-primary-600'
|
|
123
|
+
: 'bg-paper-200 text-ink-500'
|
|
124
|
+
}`}
|
|
125
|
+
>
|
|
126
|
+
{message.role === 'user' ? (
|
|
127
|
+
<User className="w-4 h-4" />
|
|
128
|
+
) : (
|
|
129
|
+
<Bot className="w-4 h-4" />
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Bubble */}
|
|
134
|
+
<div
|
|
135
|
+
className={`max-w-[75%] rounded-xl px-4 py-2.5 ${
|
|
136
|
+
message.role === 'user'
|
|
137
|
+
? 'bg-primary-500 text-white rounded-br-sm'
|
|
138
|
+
: 'bg-paper-100 text-ink-800 border border-paper-200 rounded-bl-sm'
|
|
139
|
+
}`}
|
|
140
|
+
>
|
|
141
|
+
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
|
142
|
+
<p
|
|
143
|
+
className={`text-[10px] mt-1 ${
|
|
144
|
+
message.role === 'user' ? 'text-primary-200' : 'text-ink-400'
|
|
145
|
+
}`}
|
|
146
|
+
>
|
|
147
|
+
{formatRelativeTime(message.timestamp)}
|
|
148
|
+
</p>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
))}
|
|
152
|
+
|
|
153
|
+
{/* Typing indicator */}
|
|
154
|
+
{isLoading && (
|
|
155
|
+
<div className="flex gap-3">
|
|
156
|
+
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-paper-200 text-ink-500 flex items-center justify-center">
|
|
157
|
+
<Bot className="w-4 h-4" />
|
|
158
|
+
</div>
|
|
159
|
+
<div className="bg-paper-100 border border-paper-200 rounded-xl rounded-bl-sm px-4 py-3">
|
|
160
|
+
<div className="flex gap-1.5">
|
|
161
|
+
<span className="w-2 h-2 bg-ink-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
162
|
+
<span className="w-2 h-2 bg-ink-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
163
|
+
<span className="w-2 h-2 bg-ink-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{messagesEndRef && <div ref={messagesEndRef} />}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{/* Suggested questions */}
|
|
173
|
+
{suggestedQuestions.length > 0 && messages.length === 0 && (
|
|
174
|
+
<div className="px-4 pb-2 flex flex-wrap gap-2">
|
|
175
|
+
{suggestedQuestions.map((question, index) => (
|
|
176
|
+
<button
|
|
177
|
+
key={index}
|
|
178
|
+
onClick={() => onSuggestedQuestionClick?.(question)}
|
|
179
|
+
className="text-xs px-3 py-1.5 rounded-full border border-primary-200 text-primary-700 bg-primary-50 hover:bg-primary-100 transition-colors"
|
|
180
|
+
>
|
|
181
|
+
{question}
|
|
182
|
+
</button>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{/* Input area */}
|
|
188
|
+
<div className="border-t border-paper-200 p-3 bg-paper-50">
|
|
189
|
+
<div className="flex items-end gap-2">
|
|
190
|
+
<textarea
|
|
191
|
+
value={inputValue}
|
|
192
|
+
onChange={(e) => onInputChange(e.target.value)}
|
|
193
|
+
onKeyDown={handleKeyDown}
|
|
194
|
+
placeholder={placeholder}
|
|
195
|
+
rows={1}
|
|
196
|
+
className="flex-1 resize-none rounded-lg border border-paper-300 bg-white px-3 py-2 text-sm text-ink-800 placeholder:text-ink-400 focus:outline-none focus:ring-2 focus:ring-primary-300 focus:border-primary-300"
|
|
197
|
+
/>
|
|
198
|
+
<button
|
|
199
|
+
onClick={onSend}
|
|
200
|
+
disabled={!inputValue.trim() || isLoading}
|
|
201
|
+
className="flex-shrink-0 p-2 rounded-lg bg-primary-500 text-white hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
202
|
+
aria-label="Send message"
|
|
203
|
+
>
|
|
204
|
+
<Send className="w-4 h-4" />
|
|
205
|
+
</button>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
ChatUI.displayName = 'ChatUI';
|
|
213
|
+
|
|
214
|
+
export default ChatUI;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { forwardRef } from 'react';
|
|
2
|
+
import { TrendingUp, AlertTriangle, Target, Lightbulb, RefreshCw, BrainCircuit } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
/** A structured analytics insight */
|
|
5
|
+
export interface AnalyticsInsight {
|
|
6
|
+
/** Unique insight identifier */
|
|
7
|
+
id: string;
|
|
8
|
+
/** Short insight title */
|
|
9
|
+
title: string;
|
|
10
|
+
/** Descriptive summary */
|
|
11
|
+
summary: string;
|
|
12
|
+
/** Insight category */
|
|
13
|
+
type: 'trend' | 'anomaly' | 'forecast' | 'recommendation';
|
|
14
|
+
/** Confidence score (0–1) */
|
|
15
|
+
confidence: number;
|
|
16
|
+
/** Optional payload */
|
|
17
|
+
data?: any;
|
|
18
|
+
/** When the insight was generated */
|
|
19
|
+
timestamp: Date;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface InsightsPanelUIProps {
|
|
23
|
+
/** Array of insights to display */
|
|
24
|
+
insights: AnalyticsInsight[];
|
|
25
|
+
/** Show loading skeleton */
|
|
26
|
+
isLoading?: boolean;
|
|
27
|
+
/** Active filter value */
|
|
28
|
+
filter?: string;
|
|
29
|
+
/** Maximum number of insights to show */
|
|
30
|
+
maxInsights?: number;
|
|
31
|
+
/** Filter change handler */
|
|
32
|
+
onFilterChange?: (filter: string) => void;
|
|
33
|
+
/** Refresh handler */
|
|
34
|
+
onRefresh?: () => void;
|
|
35
|
+
/** Additional CSS classes */
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const FILTER_OPTIONS = [
|
|
40
|
+
{ key: 'all', label: 'All' },
|
|
41
|
+
{ key: 'trend', label: 'Trends' },
|
|
42
|
+
{ key: 'anomaly', label: 'Anomalies' },
|
|
43
|
+
{ key: 'forecast', label: 'Forecasts' },
|
|
44
|
+
{ key: 'recommendation', label: 'Recommendations' },
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
function getTypeIcon(type: AnalyticsInsight['type']) {
|
|
48
|
+
switch (type) {
|
|
49
|
+
case 'trend':
|
|
50
|
+
return <TrendingUp className="w-4 h-4 text-green-600" />;
|
|
51
|
+
case 'anomaly':
|
|
52
|
+
return <AlertTriangle className="w-4 h-4 text-red-600" />;
|
|
53
|
+
case 'forecast':
|
|
54
|
+
return <Target className="w-4 h-4 text-purple-600" />;
|
|
55
|
+
case 'recommendation':
|
|
56
|
+
return <Lightbulb className="w-4 h-4 text-amber-600" />;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getTypeBgColor(type: AnalyticsInsight['type']): string {
|
|
61
|
+
switch (type) {
|
|
62
|
+
case 'trend': return 'bg-green-50';
|
|
63
|
+
case 'anomaly': return 'bg-red-50';
|
|
64
|
+
case 'forecast': return 'bg-purple-50';
|
|
65
|
+
case 'recommendation': return 'bg-amber-50';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getConfidenceBadge(confidence: number) {
|
|
70
|
+
const pct = Math.round(confidence * 100);
|
|
71
|
+
let colorClasses: string;
|
|
72
|
+
|
|
73
|
+
if (confidence >= 0.8) {
|
|
74
|
+
colorClasses = 'bg-green-100 text-green-700';
|
|
75
|
+
} else if (confidence >= 0.6) {
|
|
76
|
+
colorClasses = 'bg-yellow-100 text-yellow-700';
|
|
77
|
+
} else {
|
|
78
|
+
colorClasses = 'bg-red-100 text-red-700';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<span className={`inline-flex items-center text-[10px] font-medium px-1.5 py-0.5 rounded-full ${colorClasses}`}>
|
|
83
|
+
{pct}%
|
|
84
|
+
</span>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatRelativeTime(date: Date): string {
|
|
89
|
+
const now = new Date();
|
|
90
|
+
const diffMs = now.getTime() - date.getTime();
|
|
91
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
92
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
93
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
94
|
+
|
|
95
|
+
if (diffMins < 1) return 'Just now';
|
|
96
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
97
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
98
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
99
|
+
|
|
100
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* InsightsPanelUI - Structured display of analytics insights.
|
|
105
|
+
*
|
|
106
|
+
* Renders a header with title and refresh button, filter chips
|
|
107
|
+
* (All / Trends / Anomalies / Forecasts / Recommendations), and
|
|
108
|
+
* insight cards with type icon, title, summary, confidence badge,
|
|
109
|
+
* and relative timestamp. Includes loading skeleton and empty state.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```tsx
|
|
113
|
+
* <InsightsPanelUI
|
|
114
|
+
* insights={insights}
|
|
115
|
+
* isLoading={loading}
|
|
116
|
+
* filter={activeFilter}
|
|
117
|
+
* maxInsights={8}
|
|
118
|
+
* onFilterChange={setFilter}
|
|
119
|
+
* onRefresh={refreshInsights}
|
|
120
|
+
* />
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
const InsightsPanelUI = forwardRef<HTMLDivElement, InsightsPanelUIProps>(({
|
|
124
|
+
insights,
|
|
125
|
+
isLoading = false,
|
|
126
|
+
filter = 'all',
|
|
127
|
+
maxInsights,
|
|
128
|
+
onFilterChange,
|
|
129
|
+
onRefresh,
|
|
130
|
+
className = '',
|
|
131
|
+
}, ref) => {
|
|
132
|
+
const filtered = filter === 'all'
|
|
133
|
+
? insights
|
|
134
|
+
: insights.filter((i) => i.type === filter);
|
|
135
|
+
|
|
136
|
+
const displayed = maxInsights ? filtered.slice(0, maxInsights) : filtered;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div ref={ref} className={`bg-white rounded-lg border border-paper-200 ${className}`}>
|
|
140
|
+
{/* Header */}
|
|
141
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-paper-200">
|
|
142
|
+
<h3 className="text-lg font-semibold text-ink-900">AI Insights</h3>
|
|
143
|
+
{onRefresh && (
|
|
144
|
+
<button
|
|
145
|
+
onClick={onRefresh}
|
|
146
|
+
disabled={isLoading}
|
|
147
|
+
className="p-1.5 rounded-md text-ink-500 hover:text-ink-700 hover:bg-paper-100 disabled:opacity-40 transition-colors"
|
|
148
|
+
aria-label="Refresh insights"
|
|
149
|
+
>
|
|
150
|
+
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
|
151
|
+
</button>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{/* Filter chips */}
|
|
156
|
+
{onFilterChange && (
|
|
157
|
+
<div className="px-5 pt-3 pb-1 flex flex-wrap gap-2">
|
|
158
|
+
{FILTER_OPTIONS.map((opt) => (
|
|
159
|
+
<button
|
|
160
|
+
key={opt.key}
|
|
161
|
+
onClick={() => onFilterChange(opt.key)}
|
|
162
|
+
className={`text-xs font-medium px-3 py-1.5 rounded-full transition-colors ${
|
|
163
|
+
filter === opt.key
|
|
164
|
+
? 'bg-primary-500 text-white'
|
|
165
|
+
: 'bg-paper-100 text-ink-600 hover:bg-paper-200'
|
|
166
|
+
}`}
|
|
167
|
+
>
|
|
168
|
+
{opt.label}
|
|
169
|
+
</button>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{/* Content */}
|
|
175
|
+
<div className="p-5 space-y-3">
|
|
176
|
+
{/* Loading skeleton */}
|
|
177
|
+
{isLoading && displayed.length === 0 && (
|
|
178
|
+
<div className="space-y-3">
|
|
179
|
+
{[1, 2, 3].map((i) => (
|
|
180
|
+
<div key={i} className="animate-pulse rounded-lg border border-paper-200 p-4">
|
|
181
|
+
<div className="flex items-center gap-3 mb-2">
|
|
182
|
+
<div className="w-8 h-8 bg-paper-200 rounded-lg" />
|
|
183
|
+
<div className="flex-1">
|
|
184
|
+
<div className="h-4 bg-paper-200 rounded w-2/3 mb-1" />
|
|
185
|
+
<div className="h-3 bg-paper-100 rounded w-1/3" />
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
<div className="h-3 bg-paper-100 rounded w-full mb-1" />
|
|
189
|
+
<div className="h-3 bg-paper-100 rounded w-4/5" />
|
|
190
|
+
</div>
|
|
191
|
+
))}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{/* Empty state */}
|
|
196
|
+
{!isLoading && displayed.length === 0 && (
|
|
197
|
+
<div className="text-center py-8">
|
|
198
|
+
<BrainCircuit className="w-10 h-10 text-ink-300 mx-auto mb-3" />
|
|
199
|
+
<p className="text-ink-500 text-sm">
|
|
200
|
+
{filter === 'all'
|
|
201
|
+
? 'No insights available yet.'
|
|
202
|
+
: `No ${filter} insights found.`}
|
|
203
|
+
</p>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{/* Insight cards */}
|
|
208
|
+
{displayed.map((insight) => (
|
|
209
|
+
<div
|
|
210
|
+
key={insight.id}
|
|
211
|
+
className="rounded-lg border border-paper-200 p-4 hover:shadow-sm transition-shadow"
|
|
212
|
+
>
|
|
213
|
+
<div className="flex items-start gap-3">
|
|
214
|
+
{/* Type icon */}
|
|
215
|
+
<div className={`flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${getTypeBgColor(insight.type)}`}>
|
|
216
|
+
{getTypeIcon(insight.type)}
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div className="flex-1 min-w-0">
|
|
220
|
+
{/* Title row */}
|
|
221
|
+
<div className="flex items-center justify-between gap-2 mb-1">
|
|
222
|
+
<h4 className="text-sm font-medium text-ink-900 truncate">{insight.title}</h4>
|
|
223
|
+
{getConfidenceBadge(insight.confidence)}
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{/* Summary */}
|
|
227
|
+
<p className="text-xs text-ink-600 leading-relaxed">{insight.summary}</p>
|
|
228
|
+
|
|
229
|
+
{/* Timestamp */}
|
|
230
|
+
<p className="text-[10px] text-ink-400 mt-1.5">
|
|
231
|
+
{formatRelativeTime(insight.timestamp)}
|
|
232
|
+
</p>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
InsightsPanelUI.displayName = 'InsightsPanelUI';
|
|
243
|
+
|
|
244
|
+
export default InsightsPanelUI;
|
|
@@ -331,8 +331,26 @@ export default function Sidebar({
|
|
|
331
331
|
{/* Navigation */}
|
|
332
332
|
<nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
|
|
333
333
|
{items.map((item) => {
|
|
334
|
-
// Render separator
|
|
334
|
+
// Render separator or section header
|
|
335
335
|
if (item.separator) {
|
|
336
|
+
// Section header: separator with a label
|
|
337
|
+
if (item.label) {
|
|
338
|
+
return (
|
|
339
|
+
<div
|
|
340
|
+
key={item.id}
|
|
341
|
+
className="mt-6 mb-2 px-3"
|
|
342
|
+
data-testid={item.dataAttributes?.['data-testid'] || `sidebar-section-${item.id}`}
|
|
343
|
+
{...item.dataAttributes}
|
|
344
|
+
>
|
|
345
|
+
<div className="border-t border-paper-300 pt-3">
|
|
346
|
+
<span className="text-[10px] font-semibold uppercase tracking-widest text-ink-400">
|
|
347
|
+
{item.label}
|
|
348
|
+
</span>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
// Plain separator: just a line
|
|
336
354
|
return (
|
|
337
355
|
<div
|
|
338
356
|
key={item.id}
|
package/src/components/index.ts
CHANGED
|
@@ -640,3 +640,9 @@ export {
|
|
|
640
640
|
Responsive,
|
|
641
641
|
} from '../context/MobileContext';
|
|
642
642
|
export type { MobileContextValue, MobileProviderProps } from '../context/MobileContext';
|
|
643
|
+
|
|
644
|
+
// Chat & AI Components
|
|
645
|
+
export { default as ChatUI } from './ChatUI';
|
|
646
|
+
export type { ChatUIProps, ChatMessage } from './ChatUI';
|
|
647
|
+
export { default as InsightsPanelUI } from './InsightsPanelUI';
|
|
648
|
+
export type { InsightsPanelUIProps, AnalyticsInsight } from './InsightsPanelUI';
|