@dilipod/ui 0.4.23 → 0.4.25

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dilipod/ui",
3
- "version": "0.4.23",
3
+ "version": "0.4.25",
4
4
  "description": "Dilipod Design System - Shared UI components and styles",
5
5
  "author": "Dilipod <hello@dilipod.com>",
6
6
  "license": "MIT",
@@ -0,0 +1,236 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useRef, useCallback, ReactNode } from 'react'
4
+ import { Button } from './button'
5
+ import { Textarea } from './textarea'
6
+ import {
7
+ CircleNotch,
8
+ PaperPlaneTilt,
9
+ } from '@phosphor-icons/react'
10
+
11
+ // ============================================
12
+ // Types
13
+ // ============================================
14
+
15
+ export interface SupportChatMessage {
16
+ id: string
17
+ role: 'customer' | 'admin' | 'system'
18
+ content: string
19
+ type: 'message' | 'event' | 'suggestion'
20
+ created_at: string
21
+ metadata?: Record<string, unknown>
22
+ }
23
+
24
+ export interface SupportChatProps {
25
+ /** Fetch messages from this endpoint */
26
+ messagesEndpoint: string
27
+ /** POST new messages to this endpoint */
28
+ sendEndpoint: string
29
+ /** Role of the current user */
30
+ currentRole: 'customer' | 'admin'
31
+ /** Poll interval in ms (0 to disable). Default: 5000 */
32
+ pollInterval?: number
33
+ /** Placeholder for the input */
34
+ placeholder?: string
35
+ /** Custom empty state */
36
+ emptyMessage?: string | ReactNode
37
+ /** Additional CSS class */
38
+ className?: string
39
+ }
40
+
41
+ // ============================================
42
+ // Helpers
43
+ // ============================================
44
+
45
+ function formatMessageTime(dateStr: string): string {
46
+ const d = new Date(dateStr)
47
+ const now = new Date()
48
+ const diffMs = now.getTime() - d.getTime()
49
+ const diffMins = Math.floor(diffMs / 60000)
50
+ const diffHours = Math.floor(diffMs / 3600000)
51
+
52
+ if (diffMins < 1) return 'just now'
53
+ if (diffMins < 60) return `${diffMins}m ago`
54
+ if (diffHours < 24) return `${diffHours}h ago`
55
+
56
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
57
+ }
58
+
59
+ // ============================================
60
+ // Component
61
+ // ============================================
62
+
63
+ export function SupportChat({
64
+ messagesEndpoint,
65
+ sendEndpoint,
66
+ currentRole,
67
+ pollInterval = 5000,
68
+ placeholder = 'Type a message...',
69
+ emptyMessage = 'No messages yet. Start the conversation!',
70
+ className = '',
71
+ }: SupportChatProps) {
72
+ const [messages, setMessages] = useState<SupportChatMessage[]>([])
73
+ const [loading, setLoading] = useState(true)
74
+ const [sending, setSending] = useState(false)
75
+ const [inputValue, setInputValue] = useState('')
76
+ const scrollRef = useRef<HTMLDivElement>(null)
77
+ const prevMessageCountRef = useRef(0)
78
+
79
+ const fetchMessages = useCallback(async () => {
80
+ try {
81
+ const res = await fetch(messagesEndpoint)
82
+ if (!res.ok) return
83
+ const data = await res.json()
84
+ setMessages(data.messages || [])
85
+ } catch {
86
+ // Silently fail
87
+ } finally {
88
+ setLoading(false)
89
+ }
90
+ }, [messagesEndpoint])
91
+
92
+ // Initial fetch + polling
93
+ useEffect(() => {
94
+ fetchMessages()
95
+ if (pollInterval > 0) {
96
+ const interval = setInterval(fetchMessages, pollInterval)
97
+ return () => clearInterval(interval)
98
+ }
99
+ }, [fetchMessages, pollInterval])
100
+
101
+ // Auto-scroll when new messages arrive
102
+ useEffect(() => {
103
+ if (messages.length > prevMessageCountRef.current) {
104
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
105
+ }
106
+ prevMessageCountRef.current = messages.length
107
+ }, [messages.length])
108
+
109
+ const handleSend = async () => {
110
+ const content = inputValue.trim()
111
+ if (!content || sending) return
112
+
113
+ setSending(true)
114
+ setInputValue('')
115
+
116
+ // Optimistic update
117
+ const optimisticMsg: SupportChatMessage = {
118
+ id: `temp-${Date.now()}`,
119
+ role: currentRole,
120
+ content,
121
+ type: 'message',
122
+ created_at: new Date().toISOString(),
123
+ }
124
+ setMessages((prev) => [...prev, optimisticMsg])
125
+
126
+ try {
127
+ const res = await fetch(sendEndpoint, {
128
+ method: 'POST',
129
+ headers: { 'Content-Type': 'application/json' },
130
+ body: JSON.stringify({ content }),
131
+ })
132
+
133
+ if (res.ok) {
134
+ // Replace optimistic message with real one
135
+ await fetchMessages()
136
+ } else {
137
+ // Remove optimistic message on failure
138
+ setMessages((prev) => prev.filter((m) => m.id !== optimisticMsg.id))
139
+ }
140
+ } catch {
141
+ setMessages((prev) => prev.filter((m) => m.id !== optimisticMsg.id))
142
+ } finally {
143
+ setSending(false)
144
+ }
145
+ }
146
+
147
+ const handleKeyDown = (e: React.KeyboardEvent) => {
148
+ if (e.key === 'Enter' && !e.shiftKey) {
149
+ e.preventDefault()
150
+ handleSend()
151
+ }
152
+ }
153
+
154
+ if (loading) {
155
+ return (
156
+ <div className={`flex items-center justify-center py-8 ${className}`}>
157
+ <CircleNotch className="w-5 h-5 animate-spin text-muted-foreground" />
158
+ </div>
159
+ )
160
+ }
161
+
162
+ return (
163
+ <div className={`flex flex-col ${className}`}>
164
+ {/* Messages area */}
165
+ <div ref={scrollRef} className="flex-1 overflow-y-auto space-y-3 mb-4 max-h-[400px] min-h-[200px] px-1">
166
+ {messages.length === 0 ? (
167
+ <div className="flex items-center justify-center h-full text-sm text-muted-foreground py-8">
168
+ {typeof emptyMessage === 'string' ? <p>{emptyMessage}</p> : emptyMessage}
169
+ </div>
170
+ ) : (
171
+ messages.map((msg) => {
172
+ // System events — centered, subtle
173
+ if (msg.type === 'event' || msg.role === 'system') {
174
+ return (
175
+ <div key={msg.id} className="flex justify-center">
176
+ <div className="bg-gray-50 border border-gray-100 rounded-full px-3 py-1 text-xs text-muted-foreground max-w-[90%] text-center">
177
+ {msg.content}
178
+ <span className="ml-2 opacity-60">{formatMessageTime(msg.created_at)}</span>
179
+ </div>
180
+ </div>
181
+ )
182
+ }
183
+
184
+ // Regular messages
185
+ const isCurrentUser = msg.role === currentRole
186
+ return (
187
+ <div key={msg.id} className={`flex ${isCurrentUser ? 'justify-end' : 'justify-start'}`}>
188
+ <div className={`max-w-[80%] rounded-lg px-3 py-2 ${
189
+ isCurrentUser
190
+ ? 'bg-[var(--cyan)] text-white'
191
+ : 'bg-gray-100 text-[var(--black)]'
192
+ }`}>
193
+ {!isCurrentUser && (
194
+ <p className="text-xs font-medium mb-0.5 opacity-70">
195
+ {msg.role === 'admin' ? 'Dilipod Team' : 'You'}
196
+ </p>
197
+ )}
198
+ <p className="text-sm whitespace-pre-wrap">{msg.content}</p>
199
+ <p className={`text-[10px] mt-1 ${isCurrentUser ? 'text-white/60' : 'text-muted-foreground'}`}>
200
+ {formatMessageTime(msg.created_at)}
201
+ </p>
202
+ </div>
203
+ </div>
204
+ )
205
+ })
206
+ )}
207
+ </div>
208
+
209
+ {/* Input area */}
210
+ <div className="flex gap-2 border-t border-gray-100 pt-3">
211
+ <Textarea
212
+ value={inputValue}
213
+ onChange={(e) => setInputValue(e.target.value)}
214
+ onKeyDown={handleKeyDown}
215
+ placeholder={placeholder}
216
+ rows={1}
217
+ className="resize-none min-h-[36px] py-2 flex-1"
218
+ />
219
+ <Button
220
+ onClick={handleSend}
221
+ disabled={sending || !inputValue.trim()}
222
+ size="sm"
223
+ className="flex-shrink-0 h-9"
224
+ >
225
+ {sending ? (
226
+ <CircleNotch className="w-4 h-4 animate-spin" />
227
+ ) : (
228
+ <PaperPlaneTilt className="w-4 h-4" weight="bold" />
229
+ )}
230
+ </Button>
231
+ </div>
232
+ </div>
233
+ )
234
+ }
235
+
236
+ export default SupportChat
@@ -19,14 +19,45 @@ import {
19
19
  Sparkle,
20
20
  Lightbulb,
21
21
  Robot,
22
+ MagnifyingGlass,
23
+ FilmStrip,
24
+ FileText,
25
+ TextT,
26
+ VideoCamera,
27
+ ImageSquare,
22
28
  } from '@phosphor-icons/react'
23
29
  import { FlowchartDiagram } from './flowchart-diagram'
24
30
  import { Badge } from './badge'
31
+ import {
32
+ Dialog,
33
+ DialogTrigger,
34
+ DialogContent,
35
+ DialogHeader,
36
+ DialogTitle,
37
+ DialogDescription,
38
+ } from './dialog'
25
39
 
26
40
  // ============================================
27
41
  // Types
28
42
  // ============================================
29
43
 
44
+ export interface AnalysisSource {
45
+ type: string
46
+ name: string
47
+ size: number
48
+ insights?: string[]
49
+ processing_time_ms?: number
50
+ }
51
+
52
+ export interface AnalysisSummary {
53
+ total_sources: number
54
+ video_count: number
55
+ document_count: number
56
+ image_count: number
57
+ total_insights: number
58
+ analyzed_at: string
59
+ }
60
+
30
61
  export interface WorkerSpecDocumentation {
31
62
  scope: string | null
32
63
  goal: string | null
@@ -50,6 +81,9 @@ export interface WorkerSpecDocumentation {
50
81
  scenario: string
51
82
  handling: string
52
83
  }> | null
84
+ analysis_sources: AnalysisSource[] | null
85
+ analysis_summary: AnalysisSummary | null
86
+ analysis_context: string | null
53
87
  version: number
54
88
  model_used: string | null
55
89
  updated_at: string
@@ -109,6 +143,47 @@ function SectionHeader({
109
143
  )
110
144
  }
111
145
 
146
+ // ============================================
147
+ // Analysis Context Renderer
148
+ // ============================================
149
+
150
+ function AnalysisContextRenderer({ content }: { content: string }) {
151
+ const sections = content.split(/^## /gm).filter(Boolean)
152
+
153
+ const getIcon = (title: string) => {
154
+ if (title.includes('Request')) return <Target size={14} className="text-[var(--cyan)]" />
155
+ if (title.includes('Video')) return <VideoCamera size={14} className="text-[var(--cyan)]" />
156
+ if (title.includes('Document')) return <FileText size={14} className="text-[var(--cyan)]" />
157
+ if (title.includes('Rules')) return <Lightbulb size={14} className="text-amber-500" />
158
+ if (title.includes('Context')) return <TextT size={14} className="text-[var(--cyan)]" />
159
+ return null
160
+ }
161
+
162
+ return (
163
+ <div className="space-y-6">
164
+ {sections.map((section, index) => {
165
+ const lines = section.split('\n')
166
+ const title = lines[0]?.trim()
167
+ const body = lines.slice(1).join('\n').trim()
168
+
169
+ if (!body) return null
170
+
171
+ return (
172
+ <div key={index}>
173
+ <h4 className="text-sm font-medium text-[var(--black)] flex items-center gap-2 mb-3">
174
+ {getIcon(title)}
175
+ {title}
176
+ </h4>
177
+ <div className="text-sm text-muted-foreground pl-5 space-y-2 whitespace-pre-line">
178
+ {body}
179
+ </div>
180
+ </div>
181
+ )
182
+ })}
183
+ </div>
184
+ )
185
+ }
186
+
112
187
  // ============================================
113
188
  // Main Component
114
189
  // ============================================
@@ -153,9 +228,89 @@ export function WorkerSpec({ documentation, className }: WorkerSpecProps) {
153
228
  ? frequencyLabels[documentation.expected_impact.frequency] || documentation.expected_impact.frequency
154
229
  : 'occurrence'
155
230
 
231
+ const hasAnalysis = documentation.analysis_context || (documentation.analysis_sources && documentation.analysis_sources.length > 0)
232
+
156
233
  return (
157
234
  <div className={className}>
158
235
  <div className="space-y-5">
236
+ {/* What was analyzed */}
237
+ {hasAnalysis && (
238
+ <div>
239
+ <Dialog>
240
+ <DialogTrigger asChild>
241
+ <button className="flex items-center gap-2 text-sm font-medium text-[var(--cyan)] hover:text-[var(--cyan)]/80 transition-colors">
242
+ <MagnifyingGlass size={16} />
243
+ What was analyzed
244
+ {documentation.analysis_summary && (
245
+ <span className="text-xs text-muted-foreground font-normal">
246
+ ({documentation.analysis_summary.total_sources} source{documentation.analysis_summary.total_sources !== 1 ? 's' : ''})
247
+ </span>
248
+ )}
249
+ <CaretRight size={12} />
250
+ </button>
251
+ </DialogTrigger>
252
+ <DialogContent className="max-w-xl max-h-[80vh] overflow-y-auto">
253
+ <DialogHeader>
254
+ <DialogTitle>What was analyzed</DialogTitle>
255
+ {documentation.analysis_summary && (
256
+ <DialogDescription className="flex items-center gap-4 text-xs">
257
+ <span>{documentation.analysis_summary.total_sources} source{documentation.analysis_summary.total_sources !== 1 ? 's' : ''}</span>
258
+ {documentation.analysis_summary.video_count > 0 && (
259
+ <span className="flex items-center gap-1">
260
+ <FilmStrip size={12} />
261
+ {documentation.analysis_summary.video_count} video{documentation.analysis_summary.video_count !== 1 ? 's' : ''}
262
+ </span>
263
+ )}
264
+ {documentation.analysis_summary.document_count > 0 && (
265
+ <span className="flex items-center gap-1">
266
+ <FileText size={12} />
267
+ {documentation.analysis_summary.document_count} doc{documentation.analysis_summary.document_count !== 1 ? 's' : ''}
268
+ </span>
269
+ )}
270
+ </DialogDescription>
271
+ )}
272
+ </DialogHeader>
273
+
274
+ {/* Sources Section */}
275
+ {documentation.analysis_sources && documentation.analysis_sources.length > 0 && (
276
+ <div className="space-y-2">
277
+ <h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Sources</h4>
278
+ <div className="space-y-1.5">
279
+ {documentation.analysis_sources.map((source, i) => (
280
+ <div key={i} className="flex items-center gap-2 text-sm">
281
+ <div className="flex-shrink-0 text-muted-foreground">
282
+ {source.type === 'video' && <FilmStrip size={14} />}
283
+ {(source.type === 'document' || source.type === 'pdf') && <FileText size={14} />}
284
+ {source.type === 'spreadsheet' && <FileText size={14} />}
285
+ {source.type === 'image' && <ImageSquare size={14} />}
286
+ {source.type === 'description' && <TextT size={14} />}
287
+ </div>
288
+ <span className="truncate text-[var(--black)]">{source.name}</span>
289
+ {source.size > 0 && (
290
+ <span className="text-xs text-muted-foreground flex-shrink-0">
291
+ {source.size > 1024 * 1024
292
+ ? `${(source.size / 1024 / 1024).toFixed(1)}MB`
293
+ : `${Math.round(source.size / 1024)}KB`}
294
+ </span>
295
+ )}
296
+ </div>
297
+ ))}
298
+ </div>
299
+ </div>
300
+ )}
301
+
302
+ {/* Analysis Details Section */}
303
+ {documentation.analysis_context && (
304
+ <div className="border-t border-gray-100 pt-4 space-y-1">
305
+ <h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Analysis Details</h4>
306
+ <AnalysisContextRenderer content={documentation.analysis_context} />
307
+ </div>
308
+ )}
309
+ </DialogContent>
310
+ </Dialog>
311
+ </div>
312
+ )}
313
+
159
314
  {/* Goal */}
160
315
  {documentation.goal && (
161
316
  <div>
package/src/index.ts CHANGED
@@ -281,6 +281,10 @@ export type { ImpactMetrics, ImpactMetricsFormProps } from './components/impact-
281
281
  export { ActivityTimeline } from './components/activity-timeline'
282
282
  export type { Activity, ActivityTimelineProps } from './components/activity-timeline'
283
283
 
284
+ // Support Chat Components
285
+ export { SupportChat } from './components/support-chat'
286
+ export type { SupportChatMessage, SupportChatProps } from './components/support-chat'
287
+
284
288
  // Workflow Components
285
289
  export { WorkflowFlow } from './components/workflow-flow'
286
290
  export type { WorkflowFlowProps, N8nWorkflow as WorkflowFlowN8nWorkflow, N8nNode } from './components/workflow-flow'
@@ -295,7 +299,7 @@ export type {
295
299
 
296
300
  // Worker Spec Components
297
301
  export { WorkerSpec } from './components/worker-spec'
298
- export type { WorkerSpecProps, WorkerSpecDocumentation } from './components/worker-spec'
302
+ export type { WorkerSpecProps, WorkerSpecDocumentation, AnalysisSource, AnalysisSummary } from './components/worker-spec'
299
303
 
300
304
  // Flowchart Diagram Components
301
305
  export { FlowchartDiagram } from './components/flowchart-diagram'