@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
@@ -1,46 +1,21 @@
1
1
  import { useCallback, useEffect, useState } from 'react';
2
2
  import { ChatWidget } from './ChatWidget';
3
3
  import { PreChatForm } from './PreChatForm';
4
+ import { ChatBubbleIcon } from './BrandIcons';
5
+ import { useDraggablePosition } from '../hooks/useDraggablePosition';
4
6
  import type { IChatConfig, IUser } from '../types';
5
7
  import { useChatWidgetState } from '../hooks/useChatWidgetState';
6
8
  import type { ChatWidgetState } from '../hooks/useChatWidgetState';
7
9
 
8
10
  interface ChatWidgetWrapperProps {
9
- /**
10
- * Base configuration for the chat widget (without user info and conversationId)
11
- */
12
- config: Omit<IChatConfig, 'currentUser' | 'conversationId' | 'userId'> & {
13
- currentUser?: Partial<IUser>;
11
+ config: Omit<IChatConfig, 'conversationId'> & {
14
12
  conversationId?: string;
15
13
  };
16
- /**
17
- * Custom className for the wrapper
18
- */
19
14
  className?: string;
20
- /**
21
- * Storage key prefix for persisting user data
22
- * Defaults to 'xcelsior_chat'
23
- */
24
15
  storageKeyPrefix?: string;
25
- /**
26
- * Callback when user submits the pre-chat form
27
- */
28
16
  onPreChatSubmit?: (user: IUser) => void;
29
- /**
30
- * Controlled state. When provided, the component is controlled.
31
- * - 'open': Fully open with chat interface
32
- * - 'minimized': Show bubble button only
33
- * - 'closed': Fully hidden
34
- */
35
17
  state?: ChatWidgetState;
36
- /**
37
- * Default state for uncontrolled mode
38
- * @default 'minimized'
39
- */
40
18
  defaultState?: ChatWidgetState;
41
- /**
42
- * Callback when state changes
43
- */
44
19
  onStateChange?: (state: ChatWidgetState) => void;
45
20
  }
46
21
 
@@ -51,23 +26,13 @@ interface StoredUserData {
51
26
  timestamp: number;
52
27
  }
53
28
 
54
- /**
55
- * Generates a unique session-based ID
56
- */
57
29
  function generateSessionId(): string {
58
30
  if (typeof crypto !== 'undefined' && crypto.randomUUID) {
59
31
  return crypto.randomUUID();
60
32
  }
61
- // Fallback for older browsers
62
33
  return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
63
34
  }
64
35
 
65
- /**
66
- * Chat component that handles:
67
- * - Automatic conversation ID generation
68
- * - Pre-chat form for collecting user information
69
- * - Session persistence in localStorage
70
- */
71
36
  export function Chat({
72
37
  config,
73
38
  className = '',
@@ -80,22 +45,63 @@ export function Chat({
80
45
  const [userInfo, setUserInfo] = useState<IUser | null>(null);
81
46
  const [conversationId, setConversationId] = useState<string>('');
82
47
  const [isLoading, setIsLoading] = useState(true);
48
+ const [isAnimating, setIsAnimating] = useState(false);
49
+ const [showWidget, setShowWidget] = useState(false);
83
50
 
84
- // Centralized state management using the custom hook
85
- const { currentState, setState } = useChatWidgetState({
51
+ const identityMode = config.identityCollection || 'progressive';
52
+ const { position, isDragging, showHint, handlers } = useDraggablePosition(config.position);
53
+
54
+ const { currentState, setState: setStateRaw } = useChatWidgetState({
86
55
  state,
87
56
  defaultState,
88
57
  onStateChange,
89
58
  });
90
59
 
91
- // Initialize user data from localStorage or generate new session
60
+ // Wrap setState with animation handling
61
+ const setState = useCallback(
62
+ (newState: ChatWidgetState) => {
63
+ if (newState === 'open' && currentState === 'minimized') {
64
+ // Opening: show widget immediately, trigger animation
65
+ setShowWidget(true);
66
+ setIsAnimating(true);
67
+ setStateRaw(newState);
68
+ // Let the animation class apply on next frame
69
+ requestAnimationFrame(() => {
70
+ requestAnimationFrame(() => {
71
+ setIsAnimating(false);
72
+ });
73
+ });
74
+ } else if (
75
+ (newState === 'minimized' || newState === 'closed') &&
76
+ currentState === 'open'
77
+ ) {
78
+ // Closing: animate out, then change state
79
+ setIsAnimating(true);
80
+ setTimeout(() => {
81
+ setShowWidget(false);
82
+ setIsAnimating(false);
83
+ setStateRaw(newState);
84
+ }, 200);
85
+ } else {
86
+ setStateRaw(newState);
87
+ }
88
+ },
89
+ [currentState, setStateRaw]
90
+ );
91
+
92
+ // Sync showWidget with state
93
+ useEffect(() => {
94
+ if (currentState === 'open') {
95
+ setShowWidget(true);
96
+ }
97
+ }, [currentState]);
98
+
99
+ // Initialize session
92
100
  useEffect(() => {
93
101
  const initializeSession = () => {
94
102
  try {
95
- // Check if user provided initial data
96
103
  if (config.currentUser?.email && config.currentUser?.name) {
97
104
  const convId = config.conversationId || generateSessionId();
98
-
99
105
  const user: IUser = {
100
106
  name: config.currentUser.name,
101
107
  email: config.currentUser.email,
@@ -103,31 +109,24 @@ export function Chat({
103
109
  type: 'customer',
104
110
  status: config.currentUser.status,
105
111
  };
106
-
107
112
  setUserInfo(user);
108
113
  setConversationId(convId);
109
114
  setIsLoading(false);
110
115
  return;
111
116
  }
112
117
 
113
- // Try to load from localStorage
114
118
  const storedDataJson = localStorage.getItem(`${storageKeyPrefix}_user`);
115
-
116
119
  if (storedDataJson) {
117
120
  const storedData: StoredUserData = JSON.parse(storedDataJson);
118
-
119
- // Check if session is still valid (24 hours)
120
121
  const isExpired = Date.now() - storedData.timestamp > 24 * 60 * 60 * 1000;
121
122
 
122
123
  if (!isExpired && storedData.email && storedData.name) {
123
- // Restore session
124
124
  const user: IUser = {
125
125
  name: storedData.name,
126
126
  email: storedData.email,
127
127
  type: 'customer',
128
128
  status: 'online',
129
129
  };
130
-
131
130
  setUserInfo(user);
132
131
  setConversationId(storedData.conversationId);
133
132
  setIsLoading(false);
@@ -138,20 +137,11 @@ export function Chat({
138
137
  const convId = config.conversationId || generateSessionId();
139
138
  setConversationId(convId);
140
139
 
141
- // If we have partial user info, use it
142
- if (config.currentUser?.email && config.currentUser?.name) {
143
- const user: IUser = {
144
- name: config.currentUser.name,
145
- email: config.currentUser.email,
146
- avatar: config.currentUser.avatar,
147
- type: 'customer',
148
- status: 'online',
149
- };
150
- setUserInfo(user);
140
+ if (identityMode === 'progressive' || identityMode === 'none') {
141
+ setUserInfo(null);
151
142
  }
152
143
  } catch (error) {
153
144
  console.error('Error initializing chat session:', error);
154
- // Generate fallback IDs
155
145
  setConversationId(config.conversationId || generateSessionId());
156
146
  } finally {
157
147
  setIsLoading(false);
@@ -159,21 +149,13 @@ export function Chat({
159
149
  };
160
150
 
161
151
  initializeSession();
162
- }, [config, storageKeyPrefix]);
152
+ }, [config, storageKeyPrefix, identityMode]);
163
153
 
164
- // Handle pre-chat form submission
165
154
  const handlePreChatSubmit = useCallback(
166
155
  (name: string, email: string) => {
167
156
  const convId = conversationId || generateSessionId();
157
+ const user: IUser = { name, email, type: 'customer', status: 'online' };
168
158
 
169
- const user: IUser = {
170
- name,
171
- email,
172
- type: 'customer',
173
- status: 'online',
174
- };
175
-
176
- // Store in localStorage
177
159
  const storageData: StoredUserData = {
178
160
  name,
179
161
  email,
@@ -183,8 +165,8 @@ export function Chat({
183
165
 
184
166
  try {
185
167
  localStorage.setItem(`${storageKeyPrefix}_user`, JSON.stringify(storageData));
186
- } catch (error) {
187
- console.error('Error storing user data:', error);
168
+ } catch {
169
+ // Storage unavailable
188
170
  }
189
171
 
190
172
  setUserInfo(user);
@@ -194,35 +176,56 @@ export function Chat({
194
176
  [conversationId, storageKeyPrefix, onPreChatSubmit]
195
177
  );
196
178
 
197
- // Show loading state
198
- if (isLoading) {
199
- return null; // Or you could show a loading spinner
200
- }
179
+ if (isLoading) return null;
180
+ if (currentState === 'closed') return null;
201
181
 
202
- // Handle closed state - fully hidden
203
- if (currentState === 'closed') {
204
- return null;
205
- }
182
+ const positionClass = position === 'left' ? 'left-4' : 'right-4';
183
+ const primaryColor = config.theme?.primary || '#337eff';
184
+ const primaryStrong = config.theme?.primaryStrong || '#005eff';
206
185
 
207
- // Handle minimized state - show bubble button only
186
+ // FAB button only show when minimized
208
187
  if (currentState === 'minimized') {
209
188
  return (
210
- <div className={`fixed bottom-4 right-4 z-50 ${className}`}>
189
+ <div className={`fixed bottom-5 ${positionClass} z-50 ${className}`}>
211
190
  <button
212
191
  type="button"
213
192
  onClick={() => setState('open')}
214
- className="h-14 w-14 rounded-full bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg hover:shadow-xl transition-all flex items-center justify-center relative"
193
+ className={`group relative rounded-full text-white transition-all duration-300 flex items-center justify-center touch-none select-none ${
194
+ showHint ? 'animate-bounce' : ''
195
+ } ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
196
+ onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.08)'; }}
197
+ onMouseLeave={(e) => { e.currentTarget.style.transform = isDragging ? 'scale(1.1)' : 'scale(1)'; }}
198
+ style={{
199
+ width: 64,
200
+ height: 64,
201
+ background: `linear-gradient(135deg, ${primaryColor}, ${primaryStrong})`,
202
+ boxShadow: [
203
+ `0 4px 24px 0 ${primaryColor}80`,
204
+ `0 8px 32px -4px rgba(0,0,0,0.3)`,
205
+ `inset 0 1px 0 0 rgba(255,255,255,0.2)`,
206
+ ].join(', '),
207
+ }}
215
208
  aria-label="Open chat"
209
+ {...handlers}
216
210
  >
217
- <span className="text-2xl">💬</span>
211
+ <ChatBubbleIcon size={28} color="white" className="pointer-events-none" />
212
+
213
+ {/* Pulse ring — subtle blue glow */}
214
+ <span
215
+ className="absolute inset-0 rounded-full animate-ping pointer-events-none"
216
+ style={{
217
+ backgroundColor: primaryColor,
218
+ opacity: 0.15,
219
+ animationDuration: '2.5s',
220
+ }}
221
+ />
218
222
  </button>
219
223
  </div>
220
224
  );
221
225
  }
222
226
 
223
- // Open state - show either pre-chat form or chat widget
224
- // Show pre-chat form if user info is not available
225
- if (!userInfo || !userInfo.email || !userInfo.name) {
227
+ // Open state pre-chat form (form mode only)
228
+ if (identityMode === 'form' && (!userInfo || !userInfo.email || !userInfo.name)) {
226
229
  return (
227
230
  <PreChatForm
228
231
  onSubmit={handlePreChatSubmit}
@@ -235,19 +238,36 @@ export function Chat({
235
238
  );
236
239
  }
237
240
 
238
- // Show chat widget with complete config
241
+ // Open state — chat widget
239
242
  const fullConfig: IChatConfig = {
240
243
  ...config,
241
244
  conversationId,
242
- currentUser: userInfo,
245
+ currentUser: userInfo || undefined,
243
246
  };
244
247
 
248
+ // Animation styles for open/close
249
+ const widgetAnimationStyle: React.CSSProperties =
250
+ showWidget && !isAnimating
251
+ ? {
252
+ opacity: 1,
253
+ transform: 'translateY(0) scale(1)',
254
+ transition: 'opacity 0.25s ease-out, transform 0.25s ease-out',
255
+ }
256
+ : {
257
+ opacity: 0,
258
+ transform: 'translateY(12px) scale(0.97)',
259
+ transition: 'opacity 0.2s ease-in, transform 0.2s ease-in',
260
+ };
261
+
245
262
  return (
246
- <ChatWidget
247
- config={fullConfig}
248
- className={className}
249
- onClose={() => setState('closed')}
250
- onMinimize={() => setState('minimized')}
251
- />
263
+ <div style={widgetAnimationStyle}>
264
+ <ChatWidget
265
+ config={fullConfig}
266
+ className={className}
267
+ onClose={() => setState('closed')}
268
+ onMinimize={() => setState('minimized')}
269
+ resolvedPosition={position}
270
+ />
271
+ </div>
252
272
  );
253
273
  }
@@ -1,93 +1,148 @@
1
- import type { IUser } from '../types';
1
+ import type { IChatTheme, IUser } from '../types';
2
+ import { XcelsiorSymbol } from './BrandIcons';
2
3
 
3
4
  interface ChatHeaderProps {
4
5
  agent?: IUser;
5
6
  onClose?: () => void;
6
7
  onMinimize?: () => void;
8
+ theme?: IChatTheme;
7
9
  }
8
10
 
9
- export function ChatHeader({ agent, onClose, onMinimize }: ChatHeaderProps) {
11
+ export function ChatHeader({ agent, onClose, onMinimize, theme }: ChatHeaderProps) {
10
12
  return (
11
- <div className="bg-gradient-to-r from-blue-600 to-purple-600 text-white p-4 flex items-center justify-between">
12
- <div className="flex items-center gap-3">
13
- <div className="relative">
14
- <div className="h-10 w-10 rounded-full bg-white/20 flex items-center justify-center text-lg font-medium">
13
+ <div
14
+ className="relative text-white overflow-hidden"
15
+ style={{
16
+ flexShrink: 0,
17
+ /* Layered blue smoke/glow effect for premium look */
18
+ background: [
19
+ 'radial-gradient(ellipse at 30% 50%, rgba(0,50,255,0.4) 0%, transparent 60%)',
20
+ 'radial-gradient(ellipse at 70% 30%, rgba(0,80,255,0.3) 0%, transparent 50%)',
21
+ 'radial-gradient(ellipse at 50% 80%, rgba(0,30,200,0.3) 0%, transparent 60%)',
22
+ `linear-gradient(135deg, #0030cc 0%, #001a66 50%, #000d33 100%)`,
23
+ ].join(', '),
24
+ }}
25
+ >
26
+ {/* Top glass highlight — subtle light sweep for depth */}
27
+ <div
28
+ className="absolute inset-0 pointer-events-none"
29
+ style={{
30
+ background:
31
+ 'linear-gradient(180deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0) 50%)',
32
+ }}
33
+ />
34
+
35
+ <div className="flex items-center justify-between" style={{ padding: '8px 16px' }}>
36
+ <div className="flex items-center gap-3">
37
+ <div className="relative">
15
38
  {agent?.avatar ? (
16
39
  <img
17
40
  src={agent.avatar}
18
41
  alt={agent.name}
19
42
  className="h-10 w-10 rounded-full object-cover"
43
+ style={{
44
+ boxShadow: '0 2px 8px rgba(0,0,0,0.25)',
45
+ }}
20
46
  />
21
47
  ) : (
22
- '🎧'
48
+ <div style={{ filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.3))' }}>
49
+ <XcelsiorSymbol size={28} color="white" />
50
+ </div>
51
+ )}
52
+ {agent?.status === 'online' && (
53
+ <div
54
+ className="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full"
55
+ style={{
56
+ backgroundColor: theme?.statusPositive || '#1ed473',
57
+ border: '2.5px solid rgba(0,50,180,0.9)',
58
+ boxShadow: `0 0 8px ${theme?.statusPositive || '#1ed473'}80`,
59
+ }}
60
+ />
23
61
  )}
24
62
  </div>
25
- {agent?.status === 'online' && (
26
- <div className="absolute bottom-0 right-0 h-3 w-3 rounded-full bg-green-500 border-2 border-white" />
27
- )}
28
- </div>
29
- <div>
30
- <h3 className="font-semibold text-base">{agent?.name || 'Support Team'}</h3>
31
- <p className="text-xs text-white/80">
32
- {agent?.status === 'online'
33
- ? 'Online'
34
- : agent?.status === 'away'
35
- ? 'Away'
36
- : "We'll reply as soon as possible"}
37
- </p>
63
+ <div>
64
+ <h3
65
+ className="font-semibold leading-tight"
66
+ style={{
67
+ fontSize: '15px',
68
+ letterSpacing: '-0.01em',
69
+ }}
70
+ >
71
+ {agent?.name || 'Xcelsior Software'}
72
+ </h3>
73
+ <p
74
+ className="mt-0.5 leading-tight"
75
+ style={{
76
+ fontSize: '12px',
77
+ letterSpacing: '0.015em',
78
+ color: 'rgba(255,255,255,0.6)',
79
+ }}
80
+ >
81
+ {agent?.status === 'online' ? (
82
+ <span className="flex items-center gap-1.5">
83
+ <span
84
+ className="inline-block w-1.5 h-1.5 rounded-full"
85
+ style={{
86
+ backgroundColor: theme?.statusPositive || '#1ed473',
87
+ boxShadow: `0 0 4px ${theme?.statusPositive || '#1ed473'}60`,
88
+ }}
89
+ />
90
+ Online now
91
+ </span>
92
+ ) : agent?.status === 'away' ? (
93
+ 'Away'
94
+ ) : (
95
+ 'We typically reply within minutes'
96
+ )}
97
+ </p>
98
+ </div>
38
99
  </div>
39
- </div>
40
100
 
41
- <div className="flex items-center gap-2">
42
- {onMinimize && (
43
- <button
44
- type="button"
45
- onClick={onMinimize}
46
- className="p-2 hover:bg-white/10 rounded-full transition-colors"
47
- aria-label="Minimize chat"
48
- >
49
- <svg
50
- className="w-5 h-5"
51
- fill="none"
52
- viewBox="0 0 24 24"
53
- stroke="currentColor"
54
- aria-hidden="true"
55
- >
56
- <title>Minimize icon</title>
57
- <path
58
- strokeLinecap="round"
59
- strokeLinejoin="round"
60
- strokeWidth={2}
61
- d="M20 12H4"
62
- />
63
- </svg>
64
- </button>
65
- )}
66
- {onClose && (
67
- <button
68
- type="button"
69
- onClick={onClose}
70
- className="p-2 hover:bg-white/10 rounded-full transition-colors"
71
- aria-label="Close chat"
72
- >
73
- <svg
74
- className="w-5 h-5"
75
- fill="none"
76
- viewBox="0 0 24 24"
77
- stroke="currentColor"
78
- aria-hidden="true"
101
+ <div className="flex items-center gap-0.5">
102
+ {/* Single minimize button — always minimizes back to FAB bubble */}
103
+ {(onMinimize || onClose) && (
104
+ <button
105
+ type="button"
106
+ onClick={onMinimize || onClose}
107
+ className="p-2 rounded-lg transition-all duration-150"
108
+ style={{ backgroundColor: 'transparent' }}
109
+ onMouseEnter={(e) => {
110
+ e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.1)';
111
+ }}
112
+ onMouseLeave={(e) => {
113
+ e.currentTarget.style.backgroundColor = 'transparent';
114
+ }}
115
+ aria-label="Minimize chat"
79
116
  >
80
- <title>Close icon</title>
81
- <path
82
- strokeLinecap="round"
83
- strokeLinejoin="round"
84
- strokeWidth={2}
85
- d="M6 18L18 6M6 6l12 12"
86
- />
87
- </svg>
88
- </button>
89
- )}
117
+ <svg
118
+ width="18"
119
+ height="18"
120
+ fill="none"
121
+ viewBox="0 0 24 24"
122
+ stroke="currentColor"
123
+ aria-hidden="true"
124
+ >
125
+ <title>Minimize</title>
126
+ <path
127
+ strokeLinecap="round"
128
+ strokeLinejoin="round"
129
+ strokeWidth={2}
130
+ d="M6 18L18 6M6 6l12 12"
131
+ />
132
+ </svg>
133
+ </button>
134
+ )}
135
+ </div>
90
136
  </div>
137
+
138
+ {/* Bottom edge — subtle gradient line (matches website section dividers) */}
139
+ <div
140
+ className="h-px"
141
+ style={{
142
+ background:
143
+ 'linear-gradient(90deg, transparent 5%, rgba(255,255,255,0.12) 30%, rgba(255,255,255,0.12) 70%, transparent 95%)',
144
+ }}
145
+ />
91
146
  </div>
92
147
  );
93
148
  }