@xcelsior/ui-chat 1.0.8 → 2.0.1

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 (96) hide show
  1. package/dist/index.d.mts +69 -69
  2. package/dist/index.d.ts +69 -69
  3. package/dist/index.js +2458 -627
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +2457 -628
  6. package/dist/index.mjs.map +1 -1
  7. package/package.json +6 -5
  8. package/src/components/BrandIcons.stories.tsx +95 -0
  9. package/src/components/BrandIcons.tsx +84 -0
  10. package/src/components/Chat.stories.tsx +149 -16
  11. package/src/components/Chat.tsx +116 -96
  12. package/src/components/ChatHeader.tsx +124 -69
  13. package/src/components/ChatInput.tsx +253 -104
  14. package/src/components/ChatWidget.tsx +209 -63
  15. package/src/components/ConversationRating.stories.tsx +33 -0
  16. package/src/components/ConversationRating.tsx +156 -0
  17. package/src/components/MarkdownMessage.tsx +202 -0
  18. package/src/components/MessageItem.stories.tsx +253 -55
  19. package/src/components/MessageItem.tsx +223 -60
  20. package/src/components/MessageList.tsx +164 -35
  21. package/src/components/PreChatForm.tsx +236 -96
  22. package/src/components/ThinkingIndicator.tsx +370 -0
  23. package/src/components/TypingIndicator.tsx +27 -11
  24. package/src/hooks/useDraggablePosition.ts +91 -0
  25. package/src/hooks/useMessages.ts +12 -13
  26. package/src/hooks/useResizableWidget.ts +324 -0
  27. package/src/index.tsx +5 -0
  28. package/src/types.ts +51 -5
  29. package/src/utils/markdown-styles.ts +140 -0
  30. package/storybook-static/assets/BrandIcons-Cjy5INAp.js +4 -0
  31. package/storybook-static/assets/BrandIcons.stories-BeVC6svr.js +64 -0
  32. package/storybook-static/assets/Chat.stories-J_Yp51wU.js +803 -0
  33. package/storybook-static/assets/Color-YHDXOIA2-BMnd3YrF.js +1 -0
  34. package/storybook-static/assets/ConversationRating.stories-B5_QddHN.js +12 -0
  35. package/storybook-static/assets/DocsRenderer-CFRXHY34-i_W8iCu9.js +575 -0
  36. package/storybook-static/assets/MessageItem-DAaKZ9s9.js +14 -0
  37. package/storybook-static/assets/MessageItem.stories-Ckr1_scc.js +255 -0
  38. package/storybook-static/assets/ToastContext-Bty1K7ya.js +1 -0
  39. package/storybook-static/assets/chunk-XP5HYGXS-BpfKkqn7.js +1 -0
  40. package/storybook-static/assets/en-US-BukEqXxE.js +1 -0
  41. package/storybook-static/assets/entry-preview-docs-DHohToDm.js +46 -0
  42. package/storybook-static/assets/entry-preview-oDnntGcx.js +2 -0
  43. package/storybook-static/assets/iframe-CGBtu2Se.js +211 -0
  44. package/storybook-static/assets/index--qcDGAq6.js +1 -0
  45. package/storybook-static/assets/index-BLHw34Di.js +24 -0
  46. package/storybook-static/assets/index-B_4m48Mv.js +1 -0
  47. package/storybook-static/assets/index-DgH-xKnr.js +11 -0
  48. package/storybook-static/assets/index-DrFu-skq.js +6 -0
  49. package/storybook-static/assets/index-DrdPSA1J.js +240 -0
  50. package/storybook-static/assets/index-jvNEZhzf.js +1 -0
  51. package/storybook-static/assets/index-yBjzXJbu.js +9 -0
  52. package/storybook-static/assets/jsx-runtime-Cf8x2fCZ.js +9 -0
  53. package/storybook-static/assets/preview-B8lJiyuQ.js +34 -0
  54. package/storybook-static/assets/preview-BBWR9nbA.js +1 -0
  55. package/storybook-static/assets/preview-BRpahs9B.js +2 -0
  56. package/storybook-static/assets/preview-BWzBA1C2.js +396 -0
  57. package/storybook-static/assets/preview-CvbIS5ZJ.js +1 -0
  58. package/storybook-static/assets/preview-DD_OYowb.js +1 -0
  59. package/storybook-static/assets/preview-DGUiP6tS.js +7 -0
  60. package/storybook-static/assets/preview-DHQbi4pV.js +1 -0
  61. package/storybook-static/assets/preview-DUOvJmsz.js +1 -0
  62. package/storybook-static/assets/preview-DcGwT3kv.css +1 -0
  63. package/storybook-static/assets/preview-DwI0w3cI.js +1 -0
  64. package/storybook-static/assets/react-18-CALspjOX.js +1 -0
  65. package/storybook-static/assets/test-utils-BE0XkMtV.js +9 -0
  66. package/storybook-static/favicon.svg +1 -0
  67. package/storybook-static/iframe.html +666 -0
  68. package/storybook-static/index.html +177 -0
  69. package/storybook-static/index.json +1 -0
  70. package/storybook-static/nunito-sans-bold-italic.woff2 +0 -0
  71. package/storybook-static/nunito-sans-bold.woff2 +0 -0
  72. package/storybook-static/nunito-sans-italic.woff2 +0 -0
  73. package/storybook-static/nunito-sans-regular.woff2 +0 -0
  74. package/storybook-static/project.json +1 -0
  75. package/storybook-static/sb-addons/essentials-actions-3/manager-bundle.js +3 -0
  76. package/storybook-static/sb-addons/essentials-backgrounds-5/manager-bundle.js +12 -0
  77. package/storybook-static/sb-addons/essentials-controls-2/manager-bundle.js +405 -0
  78. package/storybook-static/sb-addons/essentials-docs-4/manager-bundle.js +245 -0
  79. package/storybook-static/sb-addons/essentials-measure-8/manager-bundle.js +3 -0
  80. package/storybook-static/sb-addons/essentials-outline-9/manager-bundle.js +3 -0
  81. package/storybook-static/sb-addons/essentials-toolbars-7/manager-bundle.js +3 -0
  82. package/storybook-static/sb-addons/essentials-viewport-6/manager-bundle.js +3 -0
  83. package/storybook-static/sb-addons/interactions-10/manager-bundle.js +222 -0
  84. package/storybook-static/sb-addons/links-1/manager-bundle.js +3 -0
  85. package/storybook-static/sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js +3 -0
  86. package/storybook-static/sb-common-assets/favicon.svg +1 -0
  87. package/storybook-static/sb-common-assets/nunito-sans-bold-italic.woff2 +0 -0
  88. package/storybook-static/sb-common-assets/nunito-sans-bold.woff2 +0 -0
  89. package/storybook-static/sb-common-assets/nunito-sans-italic.woff2 +0 -0
  90. package/storybook-static/sb-common-assets/nunito-sans-regular.woff2 +0 -0
  91. package/storybook-static/sb-manager/globals-module-info.js +1052 -0
  92. package/storybook-static/sb-manager/globals-runtime.js +42127 -0
  93. package/storybook-static/sb-manager/globals.js +48 -0
  94. package/storybook-static/sb-manager/runtime.js +12048 -0
  95. package/.turbo/turbo-build.log +0 -22
  96. package/.turbo/turbo-lint.log +0 -5
@@ -3,32 +3,44 @@ import { useWebSocket } from '../hooks/useWebSocket';
3
3
  import { useMessages } from '../hooks/useMessages';
4
4
  import { useFileUpload } from '../hooks/useFileUpload';
5
5
  import { useTypingIndicator } from '../hooks/useTypingIndicator';
6
+ import { useResizableWidget } from '../hooks/useResizableWidget';
6
7
  import { ChatHeader } from './ChatHeader';
7
8
  import { MessageList } from './MessageList';
8
9
  import { ChatInput } from './ChatInput';
9
- import type { IChatConfig, IMessage } from '../types';
10
+ import type { IChatConfig, IMessage, IUser } from '../types';
10
11
 
11
12
  export interface ChatWidgetProps {
12
13
  config: IChatConfig;
13
14
  className?: string;
14
- /**
15
- * Variant of the chat widget:
16
- * - 'popover': Fixed positioned floating widget (default)
17
- * - 'fullPage': Full page layout that fills the container
18
- */
19
15
  variant?: 'popover' | 'fullPage';
20
- /**
21
- * External WebSocket connection (for agents with global connection)
22
- */
23
16
  externalWebSocket?: WebSocket | null;
24
- /**
25
- * Callback when user wants to minimize the widget
26
- */
27
17
  onMinimize?: () => void;
28
- /**
29
- * Callback when user wants to close the widget
30
- */
31
18
  onClose?: () => void;
19
+ /** Resolved position (left or right) from the parent Chat component */
20
+ resolvedPosition?: 'left' | 'right';
21
+ }
22
+
23
+ /** Generate an anonymous user for progressive identity mode */
24
+ function getAnonymousUser(): IUser {
25
+ let anonId: string;
26
+ try {
27
+ const stored = localStorage.getItem('xcelsior-chat-anon-id');
28
+ if (stored) {
29
+ anonId = stored;
30
+ } else {
31
+ anonId = `anon-${crypto.randomUUID?.() || Math.random().toString(36).slice(2)}`;
32
+ localStorage.setItem('xcelsior-chat-anon-id', anonId);
33
+ }
34
+ } catch {
35
+ anonId = `anon-${Math.random().toString(36).slice(2)}`;
36
+ }
37
+
38
+ return {
39
+ name: 'Visitor',
40
+ email: `${anonId}@anonymous.xcelsior.co`,
41
+ type: 'customer',
42
+ status: 'online',
43
+ };
32
44
  }
33
45
 
34
46
  export function ChatWidget({
@@ -38,25 +50,32 @@ export function ChatWidget({
38
50
  externalWebSocket,
39
51
  onMinimize,
40
52
  onClose,
53
+ resolvedPosition = 'right',
41
54
  }: ChatWidgetProps) {
42
55
  const isFullPage = variant === 'fullPage';
43
56
 
44
- // Initialize WebSocket connection (or use external one)
45
- const websocket = useWebSocket(config, externalWebSocket);
57
+ // Resolve user support anonymous mode for progressive identity
58
+ const effectiveUser = config.currentUser || getAnonymousUser();
59
+ const effectiveConfig = { ...config, currentUser: effectiveUser };
46
60
 
47
- // Initialize messages
48
- const { messages, addMessage, isLoading, loadMore, hasMore, isLoadingMore } = useMessages(
49
- websocket,
50
- config
51
- );
61
+ const websocket = useWebSocket(effectiveConfig, externalWebSocket);
52
62
 
53
- // Initialize file upload
54
- const fileUpload = useFileUpload(config.apiKey, config.fileUpload);
63
+ const { messages, addMessage, isLoading, loadMore, hasMore, isLoadingMore, isBotThinking } =
64
+ useMessages(websocket, effectiveConfig);
55
65
 
56
- // Initialize typing indicator
66
+ const fileUpload = useFileUpload(config.apiKey, config.fileUpload);
57
67
  const { isTyping, typingUsers } = useTypingIndicator(websocket);
58
68
 
59
- // Handle sending messages
69
+ const { width, height, isResizing, isNearEdge, containerResizeProps } = useResizableWidget({
70
+ initialWidth: 380,
71
+ initialHeight: 580,
72
+ minWidth: 320,
73
+ minHeight: 400,
74
+ maxWidth: 800,
75
+ maxHeight: 900,
76
+ enabled: !isFullPage,
77
+ });
78
+
60
79
  const handleSendMessage = useCallback(
61
80
  (content: string) => {
62
81
  if (!websocket.isConnected) {
@@ -64,41 +83,33 @@ export function ChatWidget({
64
83
  return;
65
84
  }
66
85
 
67
- // Create optimistic message
68
86
  const optimisticMessage: IMessage = {
69
87
  id: `temp-${Date.now()}`,
70
88
  conversationId: config.conversationId || '',
71
- senderId: config.currentUser.email,
72
- senderType: config.currentUser.type,
89
+ senderId: effectiveUser.email,
90
+ senderType: effectiveUser.type,
73
91
  content,
74
92
  messageType: 'text',
75
93
  createdAt: new Date().toISOString(),
76
94
  status: 'sent',
77
95
  };
78
96
 
79
- // Add to local state immediately (optimistic update)
80
97
  addMessage(optimisticMessage);
81
98
 
82
- // Send via WebSocket
83
99
  websocket.sendMessage('sendMessage', {
84
100
  conversationId: config.conversationId,
85
101
  content,
86
102
  messageType: 'text',
87
103
  });
88
104
 
89
- // Call callback
90
105
  config.onMessageSent?.(optimisticMessage);
91
106
  },
92
- [websocket, config, addMessage]
107
+ [websocket, config, addMessage, effectiveUser]
93
108
  );
94
109
 
95
- // Handle typing indicator
96
110
  const handleTyping = useCallback(
97
111
  (isTyping: boolean) => {
98
- if (!websocket.isConnected || config.enableTypingIndicator === false) {
99
- return;
100
- }
101
-
112
+ if (!websocket.isConnected || config.enableTypingIndicator === false) return;
102
113
  websocket.sendMessage('typing', {
103
114
  conversationId: config.conversationId,
104
115
  isTyping,
@@ -107,35 +118,100 @@ export function ChatWidget({
107
118
  [websocket, config]
108
119
  );
109
120
 
110
- // Handle errors
111
121
  useEffect(() => {
112
122
  if (websocket.error) {
113
123
  config.toast?.error(websocket.error.message || 'An error occurred');
114
124
  }
115
125
  }, [websocket.error, config]);
116
126
 
117
- // Container styles based on variant
118
- const containerClasses = isFullPage
119
- ? `flex flex-col bg-white dark:bg-gray-900 h-full ${className}`
120
- : `fixed bottom-4 right-4 z-50 flex flex-col bg-white dark:bg-gray-900 rounded-lg shadow-2xl overflow-hidden ${className}`;
127
+ // Theme tokens aligned with Xcelsior website design system
128
+ const bgColor = config.theme?.background || '#00001a';
129
+ const bgAlt = config.theme?.backgroundAlt || '#02164a';
130
+ const textColor = config.theme?.text || '#f7f7f8';
131
+ const primaryColor = config.theme?.primary || '#337eff';
132
+ const textMuted = config.theme?.textMuted || 'rgba(247,247,248,0.45)';
121
133
 
122
- const containerStyle = isFullPage
123
- ? undefined
134
+ // Detect light vs dark theme based on background luminance
135
+ const isLightTheme = (() => {
136
+ if (!bgColor.startsWith('#')) return false;
137
+ const hex = bgColor.replace('#', '');
138
+ const r = parseInt(hex.substring(0, 2), 16);
139
+ const g = parseInt(hex.substring(2, 4), 16);
140
+ const b = parseInt(hex.substring(4, 6), 16);
141
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.5;
142
+ })();
143
+
144
+ // Position-aware container styles
145
+ const positionClass = resolvedPosition === 'left' ? 'left-4' : 'right-4';
146
+
147
+ const containerStyle: React.CSSProperties = isFullPage
148
+ ? { backgroundColor: bgColor, color: textColor }
149
+ : {
150
+ position: 'relative',
151
+ width,
152
+ height,
153
+ maxHeight: 'calc(100vh - 100px)',
154
+ backgroundColor: bgColor,
155
+ color: textColor,
156
+ borderRadius: 16,
157
+ /* Xcelsior card pattern: inset box-shadow borders instead of border-image */
158
+ boxShadow: isLightTheme
159
+ ? [
160
+ '0 25px 60px -12px rgba(0,0,0,0.15)',
161
+ '0 0 0 1px rgba(0,0,0,0.08)',
162
+ ].join(', ')
163
+ : [
164
+ 'inset 0 0 0 0.5px rgba(255,255,255,0.06)',
165
+ 'inset 0 1px 0 0 rgba(255,255,255,0.12)',
166
+ '0 25px 60px -12px rgba(0,0,0,0.6)',
167
+ '0 0 40px -8px rgba(51,126,255,0.15)',
168
+ ].join(', '),
169
+ userSelect: isResizing ? 'none' : undefined,
170
+ };
171
+
172
+ /* Dot grid texture matching the Xcelsior website background */
173
+ const dotGridBg: React.CSSProperties = !isFullPage
174
+ ? {
175
+ backgroundImage: isLightTheme
176
+ ? `radial-gradient(circle, rgba(0,0,0,0.04) 1px, transparent 1px)`
177
+ : `radial-gradient(circle, rgba(255,255,255,0.03) 1px, transparent 1px)`,
178
+ backgroundSize: '24px 24px',
179
+ }
180
+ : {};
181
+
182
+ // Subtle edge glow when user hovers near a resize edge
183
+ // Uses outline (not clipped by overflow:hidden) for visibility
184
+ const edgeHintStyle: React.CSSProperties = !isFullPage && isNearEdge
185
+ ? {
186
+ outline: isLightTheme
187
+ ? '2px solid rgba(0,94,255,0.25)'
188
+ : '2px solid rgba(51,126,255,0.35)',
189
+ outlineOffset: -1,
190
+ transition: 'outline 150ms ease',
191
+ }
124
192
  : {
125
- width: '400px',
126
- height: '600px',
127
- maxHeight: 'calc(100vh - 2rem)',
193
+ outline: '2px solid transparent',
194
+ outlineOffset: -1,
195
+ transition: 'outline 150ms ease',
128
196
  };
129
197
 
130
198
  return (
131
- <div className={containerClasses} style={containerStyle}>
199
+ <div
200
+ className={
201
+ isFullPage
202
+ ? `flex flex-col h-full ${className}`
203
+ : `fixed bottom-20 ${positionClass} z-50 flex flex-col overflow-hidden ${className}`
204
+ }
205
+ style={{ ...containerStyle, ...dotGridBg, ...edgeHintStyle }}
206
+ {...(!isFullPage ? containerResizeProps : {})}
207
+ >
132
208
  {!isFullPage && (
133
209
  <ChatHeader
134
210
  agent={
135
- config.currentUser.type === 'customer'
211
+ effectiveUser.type === 'customer'
136
212
  ? {
137
213
  email: 'contact@xcelsior.co',
138
- name: 'Support Agent',
214
+ name: 'Xcelsior Software',
139
215
  type: 'agent',
140
216
  status: websocket.isConnected ? 'online' : 'offline',
141
217
  }
@@ -143,21 +219,49 @@ export function ChatWidget({
143
219
  }
144
220
  onMinimize={onMinimize}
145
221
  onClose={onClose}
222
+ theme={config.theme}
146
223
  />
147
224
  )}
148
225
 
149
226
  {/* Connection Status */}
150
227
  {!websocket.isConnected && (
151
- <div className="bg-yellow-50 dark:bg-yellow-900/30 border-b border-yellow-200 dark:border-yellow-800 px-4 py-2">
228
+ <div
229
+ className="px-4 py-2"
230
+ style={{
231
+ backgroundColor: isLightTheme ? 'rgba(255,169,56,0.08)' : 'rgba(255,169,56,0.06)',
232
+ borderBottom: `1px solid rgba(255,169,56,${isLightTheme ? '0.15' : '0.12'})`,
233
+ }}
234
+ >
152
235
  <div className="flex items-center gap-2">
153
- <div className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse" />
154
- <span className="text-sm text-yellow-800 dark:text-yellow-200">
236
+ <div
237
+ className="w-1.5 h-1.5 rounded-full animate-pulse"
238
+ style={{ backgroundColor: config.theme?.statusCaution || '#ffa938' }}
239
+ />
240
+ <span
241
+ style={{
242
+ fontSize: '12px',
243
+ letterSpacing: '0.015em',
244
+ color: config.theme?.statusCaution || '#ffa938',
245
+ }}
246
+ >
155
247
  Reconnecting...
156
248
  </span>
157
249
  <button
158
250
  type="button"
159
251
  onClick={websocket.reconnect}
160
- className="ml-auto text-xs text-yellow-700 dark:text-yellow-300 hover:underline"
252
+ className="ml-auto transition-opacity"
253
+ style={{
254
+ fontSize: '12px',
255
+ letterSpacing: '0.015em',
256
+ color: config.theme?.statusCaution || '#ffa938',
257
+ opacity: 0.7,
258
+ }}
259
+ onMouseEnter={(e) => {
260
+ e.currentTarget.style.opacity = '1';
261
+ }}
262
+ onMouseLeave={(e) => {
263
+ e.currentTarget.style.opacity = '0.7';
264
+ }}
161
265
  >
162
266
  Retry
163
267
  </button>
@@ -169,8 +273,20 @@ export function ChatWidget({
169
273
  {isLoading ? (
170
274
  <div className="flex-1 flex items-center justify-center">
171
275
  <div className="text-center">
172
- <div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
173
- <p className="text-sm text-gray-600 dark:text-gray-400">
276
+ <div
277
+ className="w-7 h-7 border-2 border-t-transparent rounded-full animate-spin mx-auto mb-3"
278
+ style={{
279
+ borderColor: primaryColor,
280
+ borderTopColor: 'transparent',
281
+ }}
282
+ />
283
+ <p
284
+ style={{
285
+ fontSize: '12px',
286
+ letterSpacing: '0.015em',
287
+ color: textMuted,
288
+ }}
289
+ >
174
290
  Loading messages...
175
291
  </p>
176
292
  </div>
@@ -178,13 +294,16 @@ export function ChatWidget({
178
294
  ) : (
179
295
  <MessageList
180
296
  messages={messages}
181
- currentUser={config.currentUser}
297
+ currentUser={effectiveUser}
182
298
  isTyping={isTyping}
183
299
  typingUser={typingUsers[0]}
184
300
  autoScroll={true}
185
301
  onLoadMore={loadMore}
186
302
  hasMore={hasMore}
187
303
  isLoadingMore={isLoadingMore}
304
+ theme={config.theme}
305
+ onQuickAction={handleSendMessage}
306
+ isBotThinking={isBotThinking}
188
307
  />
189
308
  )}
190
309
 
@@ -192,19 +311,46 @@ export function ChatWidget({
192
311
  <ChatInput
193
312
  onSend={handleSendMessage}
194
313
  onTyping={handleTyping}
195
- config={config}
314
+ config={effectiveConfig}
196
315
  fileUpload={fileUpload}
197
316
  disabled={!websocket.isConnected}
198
317
  />
199
318
 
200
- {/* Powered by footer - only for popover */}
319
+ {/* Powered by footer */}
201
320
  {!isFullPage && (
202
- <div className="bg-gray-50 dark:bg-gray-950 px-4 py-2 text-center border-t border-gray-200 dark:border-gray-700">
203
- <p className="text-xs text-gray-500 dark:text-gray-400">
204
- Powered by <span className="font-semibold">Xcelsior Chat</span>
321
+ <div
322
+ className="px-4 py-1.5 text-center"
323
+ style={{
324
+ borderTop: isLightTheme
325
+ ? '1px solid rgba(0,0,0,0.06)'
326
+ : '1px solid rgba(255,255,255,0.06)',
327
+ backgroundColor: isLightTheme
328
+ ? 'rgba(0,0,0,0.03)'
329
+ : 'rgba(0,0,0,0.2)',
330
+ }}
331
+ >
332
+ <p
333
+ style={{
334
+ fontSize: '10px',
335
+ letterSpacing: '0.025em',
336
+ color: isLightTheme
337
+ ? 'rgba(0,0,0,0.35)'
338
+ : 'rgba(247,247,248,0.28)',
339
+ }}
340
+ >
341
+ Powered by{' '}
342
+ <span style={{
343
+ fontWeight: 600,
344
+ color: isLightTheme
345
+ ? 'rgba(0,0,0,0.5)'
346
+ : 'rgba(247,247,248,0.45)',
347
+ }}>
348
+ Xcelsior
349
+ </span>
205
350
  </p>
206
351
  </div>
207
352
  )}
353
+
208
354
  </div>
209
355
  );
210
356
  }
@@ -0,0 +1,33 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { ConversationRating } from './ConversationRating';
3
+
4
+ const meta: Meta<typeof ConversationRating> = {
5
+ title: 'Components/ConversationRating',
6
+ component: ConversationRating,
7
+ parameters: {
8
+ layout: 'padded',
9
+ },
10
+ tags: ['autodocs'],
11
+ };
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof ConversationRating>;
15
+
16
+ /** Default state — shows thumbs up/down buttons */
17
+ export const Default: Story = {
18
+ args: {
19
+ onRate: (rating) => console.log('Rated:', rating),
20
+ },
21
+ };
22
+
23
+ /** In a dark container (matching chat widget) */
24
+ export const InDarkContainer: Story = {
25
+ render: (args) => (
26
+ <div className="max-w-md p-4 bg-gray-900 rounded-lg">
27
+ <ConversationRating {...args} />
28
+ </div>
29
+ ),
30
+ args: {
31
+ onRate: (rating) => console.log('Rated:', rating),
32
+ },
33
+ };
@@ -0,0 +1,156 @@
1
+ import { useState } from 'react';
2
+ import type { IChatTheme } from '../types';
3
+
4
+ interface ConversationRatingProps {
5
+ onRate: (rating: 'positive' | 'negative') => void;
6
+ theme?: IChatTheme;
7
+ }
8
+
9
+ export function ConversationRating({ onRate, theme }: ConversationRatingProps) {
10
+ const [rated, setRated] = useState<'positive' | 'negative' | null>(null);
11
+ const [hovering, setHovering] = useState<'positive' | 'negative' | null>(null);
12
+
13
+ const textMuted = theme?.textMuted || 'rgba(247,247,248,0.45)';
14
+ const textColor = theme?.text || '#f7f7f8';
15
+ const positiveColor = theme?.statusPositive || '#1ed473';
16
+ const negativeColor = theme?.statusNegative || '#ff6363';
17
+
18
+ const handleRate = (rating: 'positive' | 'negative') => {
19
+ setRated(rating);
20
+ onRate(rating);
21
+ };
22
+
23
+ if (rated) {
24
+ return (
25
+ <div
26
+ className="flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl mx-4 my-2"
27
+ style={{
28
+ backgroundColor: 'rgba(255,255,255,0.03)',
29
+ boxShadow: 'inset 0 0 0 0.5px rgba(255,255,255,0.06)',
30
+ }}
31
+ >
32
+ <svg
33
+ width="14"
34
+ height="14"
35
+ viewBox="0 0 24 24"
36
+ fill="none"
37
+ stroke={positiveColor}
38
+ strokeWidth="2"
39
+ strokeLinecap="round"
40
+ strokeLinejoin="round"
41
+ aria-hidden="true"
42
+ >
43
+ <title>Thanks</title>
44
+ <polyline points="20 6 9 17 4 12" />
45
+ </svg>
46
+ <span
47
+ style={{
48
+ fontSize: '12px',
49
+ letterSpacing: '0.015em',
50
+ color: textMuted,
51
+ }}
52
+ >
53
+ Thanks for your feedback!
54
+ </span>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <div
61
+ className="flex flex-col items-center gap-2.5 py-3 px-4 rounded-xl mx-4 my-2"
62
+ style={{
63
+ backgroundColor: 'rgba(255,255,255,0.03)',
64
+ boxShadow: 'inset 0 0 0 0.5px rgba(255,255,255,0.06)',
65
+ }}
66
+ >
67
+ <p
68
+ style={{
69
+ fontSize: '12px',
70
+ letterSpacing: '0.015em',
71
+ color: textMuted,
72
+ }}
73
+ >
74
+ How was your experience?
75
+ </p>
76
+ <div className="flex gap-2.5">
77
+ {/* Positive rating button — ghost pill matching website button style */}
78
+ <button
79
+ type="button"
80
+ onClick={() => handleRate('positive')}
81
+ onMouseEnter={() => setHovering('positive')}
82
+ onMouseLeave={() => setHovering(null)}
83
+ className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-full font-medium transition-all duration-200"
84
+ style={{
85
+ fontSize: '12px',
86
+ letterSpacing: '0.015em',
87
+ boxShadow:
88
+ hovering === 'positive'
89
+ ? `inset 0 0 0 1px ${positiveColor}40, 0 0 12px -4px ${positiveColor}25`
90
+ : 'inset 0 0 0 0.5px rgba(255,255,255,0.08)',
91
+ backgroundColor:
92
+ hovering === 'positive'
93
+ ? `${positiveColor}10`
94
+ : 'rgba(255,255,255,0.02)',
95
+ color: hovering === 'positive' ? positiveColor : textColor,
96
+ }}
97
+ aria-label="Rate positive"
98
+ >
99
+ <svg
100
+ width="14"
101
+ height="14"
102
+ viewBox="0 0 24 24"
103
+ fill="none"
104
+ stroke="currentColor"
105
+ strokeWidth="2"
106
+ strokeLinecap="round"
107
+ strokeLinejoin="round"
108
+ aria-hidden="true"
109
+ >
110
+ <title>Thumbs up</title>
111
+ <path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" />
112
+ </svg>
113
+ Helpful
114
+ </button>
115
+ {/* Negative rating button */}
116
+ <button
117
+ type="button"
118
+ onClick={() => handleRate('negative')}
119
+ onMouseEnter={() => setHovering('negative')}
120
+ onMouseLeave={() => setHovering(null)}
121
+ className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-full font-medium transition-all duration-200"
122
+ style={{
123
+ fontSize: '12px',
124
+ letterSpacing: '0.015em',
125
+ boxShadow:
126
+ hovering === 'negative'
127
+ ? `inset 0 0 0 1px ${negativeColor}40, 0 0 12px -4px ${negativeColor}25`
128
+ : 'inset 0 0 0 0.5px rgba(255,255,255,0.08)',
129
+ backgroundColor:
130
+ hovering === 'negative'
131
+ ? `${negativeColor}10`
132
+ : 'rgba(255,255,255,0.02)',
133
+ color: hovering === 'negative' ? negativeColor : textColor,
134
+ }}
135
+ aria-label="Rate negative"
136
+ >
137
+ <svg
138
+ width="14"
139
+ height="14"
140
+ viewBox="0 0 24 24"
141
+ fill="none"
142
+ stroke="currentColor"
143
+ strokeWidth="2"
144
+ strokeLinecap="round"
145
+ strokeLinejoin="round"
146
+ aria-hidden="true"
147
+ >
148
+ <title>Thumbs down</title>
149
+ <path d="M10 15V19a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17" />
150
+ </svg>
151
+ Not helpful
152
+ </button>
153
+ </div>
154
+ </div>
155
+ );
156
+ }