@xcelsior/ui-chat 2.0.3 → 2.0.5

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 (54) hide show
  1. package/.storybook/preview.tsx +2 -1
  2. package/dist/index.d.mts +58 -5
  3. package/dist/index.d.ts +58 -5
  4. package/dist/index.js +1073 -473
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +1060 -463
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +4 -2
  9. package/src/components/BookingCancelledCard.tsx +103 -0
  10. package/src/components/BookingCards.stories.tsx +102 -0
  11. package/src/components/BookingConfirmationCard.tsx +170 -0
  12. package/src/components/BookingSlotPicker.stories.tsx +87 -0
  13. package/src/components/BookingSlotPicker.tsx +253 -0
  14. package/src/components/BrandIcons.stories.tsx +32 -1
  15. package/src/components/BrandIcons.tsx +21 -17
  16. package/src/components/Chat.tsx +78 -83
  17. package/src/components/ChatWidget.tsx +30 -3
  18. package/src/components/MessageItem.tsx +83 -72
  19. package/src/components/MessageList.tsx +4 -0
  20. package/src/hooks/useDraggablePosition.ts +147 -42
  21. package/src/hooks/useMessages.ts +119 -45
  22. package/src/hooks/useWebSocket.ts +17 -4
  23. package/src/index.tsx +11 -0
  24. package/src/types.ts +39 -2
  25. package/src/utils/api.ts +1 -0
  26. package/storybook-static/assets/BookingCancelledCard-XHuB-Ebp.js +31 -0
  27. package/storybook-static/assets/BookingCards.stories-DfJ482RS.js +66 -0
  28. package/storybook-static/assets/BookingSlotPicker-BkfssueW.js +1 -0
  29. package/storybook-static/assets/BookingSlotPicker.stories-fYlg1zLg.js +50 -0
  30. package/storybook-static/assets/BrandIcons-BsRAdWzL.js +4 -0
  31. package/storybook-static/assets/BrandIcons.stories-C6gBovfU.js +106 -0
  32. package/storybook-static/assets/Chat.stories-BrR7LHsz.js +830 -0
  33. package/storybook-static/assets/{Color-YHDXOIA2-BMnd3YrF.js → Color-YHDXOIA2-azE51u2m.js} +1 -1
  34. package/storybook-static/assets/{DocsRenderer-CFRXHY34-i_W8iCu9.js → DocsRenderer-CFRXHY34-jTmzKIDk.js} +3 -3
  35. package/storybook-static/assets/MessageItem-pEOwuLyh.js +34 -0
  36. package/storybook-static/assets/MessageItem.stories-Cs5Vtkle.js +422 -0
  37. package/storybook-static/assets/{entry-preview-oDnntGcx.js → entry-preview-vcpiajAT.js} +1 -1
  38. package/storybook-static/assets/globe-BtMvkLMD.js +31 -0
  39. package/storybook-static/assets/{iframe-CGBtu2Se.js → iframe-Cx1n-SeE.js} +2 -2
  40. package/storybook-static/assets/preview-B8y-wc-n.css +1 -0
  41. package/storybook-static/assets/preview-CC4t7T7W.js +1 -0
  42. package/storybook-static/assets/{preview-BRpahs9B.js → preview-Do3b3dZv.js} +2 -2
  43. package/storybook-static/iframe.html +1 -1
  44. package/storybook-static/index.json +1 -1
  45. package/storybook-static/project.json +1 -1
  46. package/tsconfig.json +4 -0
  47. package/storybook-static/assets/BrandIcons-Cjy5INAp.js +0 -4
  48. package/storybook-static/assets/BrandIcons.stories-BeVC6svr.js +0 -64
  49. package/storybook-static/assets/Chat.stories-J_Yp51wU.js +0 -803
  50. package/storybook-static/assets/MessageItem-DAaKZ9s9.js +0 -14
  51. package/storybook-static/assets/MessageItem.stories-Ckr1_scc.js +0 -255
  52. package/storybook-static/assets/ToastContext-Bty1K7ya.js +0 -1
  53. package/storybook-static/assets/preview-DUOvJmsz.js +0 -1
  54. package/storybook-static/assets/preview-DcGwT3kv.css +0 -1
@@ -0,0 +1,253 @@
1
+ import { useState, useCallback } from 'react';
2
+ import { Calendar, Clock, Globe } from 'lucide-react';
3
+ import type { IBookingData, IChatTheme } from '../types';
4
+
5
+ interface BookingSlotPickerProps {
6
+ data: IBookingData;
7
+ theme?: IChatTheme;
8
+ onSlotSelected: (date: string, time: string) => void;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ export function BookingSlotPicker({ data, theme, onSlotSelected, disabled = false }: BookingSlotPickerProps) {
13
+ const [selectedDateIndex, setSelectedDateIndex] = useState(0);
14
+ const [selectedSlot, setSelectedSlot] = useState<{ date: string; time: string } | null>(null);
15
+
16
+ const bgColor = theme?.background || '#00001a';
17
+ const isLightTheme = (() => {
18
+ if (!bgColor.startsWith('#')) return false;
19
+ const hex = bgColor.replace('#', '');
20
+ const r = parseInt(hex.substring(0, 2), 16);
21
+ const g = parseInt(hex.substring(2, 4), 16);
22
+ const b = parseInt(hex.substring(4, 6), 16);
23
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.5;
24
+ })();
25
+
26
+ const primaryColor = theme?.primary || '#337eff';
27
+ const textColor = theme?.text || (isLightTheme ? '#1a1a2e' : '#f7f7f8');
28
+ const textMuted = theme?.textMuted || (isLightTheme ? 'rgba(0,0,0,0.45)' : 'rgba(247,247,248,0.4)');
29
+
30
+ const cardStyle: React.CSSProperties = isLightTheme
31
+ ? {
32
+ backgroundColor: 'rgba(0,0,0,0.03)',
33
+ boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.08)',
34
+ borderRadius: '14px',
35
+ padding: '14px',
36
+ }
37
+ : {
38
+ backgroundColor: 'rgba(255,255,255,0.04)',
39
+ boxShadow: 'inset 0 0 0 0.5px rgba(255,255,255,0.08), inset 0 1px 0 0 rgba(255,255,255,0.1)',
40
+ borderRadius: '14px',
41
+ padding: '14px',
42
+ };
43
+
44
+ const activePillStyle: React.CSSProperties = {
45
+ backgroundColor: primaryColor,
46
+ color: '#ffffff',
47
+ borderRadius: '20px',
48
+ padding: '5px 12px',
49
+ fontSize: '12px',
50
+ fontWeight: '600',
51
+ letterSpacing: '0.01em',
52
+ cursor: disabled ? 'default' : 'pointer',
53
+ border: 'none',
54
+ flexShrink: 0,
55
+ };
56
+
57
+ const inactivePillStyle: React.CSSProperties = isLightTheme
58
+ ? {
59
+ backgroundColor: 'rgba(0,0,0,0.04)',
60
+ color: textColor,
61
+ borderRadius: '20px',
62
+ padding: '5px 12px',
63
+ fontSize: '12px',
64
+ fontWeight: '500',
65
+ letterSpacing: '0.01em',
66
+ cursor: disabled ? 'default' : 'pointer',
67
+ border: 'none',
68
+ boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.1)',
69
+ flexShrink: 0,
70
+ }
71
+ : {
72
+ backgroundColor: 'rgba(255,255,255,0.05)',
73
+ color: textColor,
74
+ borderRadius: '20px',
75
+ padding: '5px 12px',
76
+ fontSize: '12px',
77
+ fontWeight: '500',
78
+ letterSpacing: '0.01em',
79
+ cursor: disabled ? 'default' : 'pointer',
80
+ border: 'none',
81
+ boxShadow: 'inset 0 0 0 0.5px rgba(255,255,255,0.1)',
82
+ flexShrink: 0,
83
+ };
84
+
85
+ const activeSlot = selectedSlot?.date === data.availableDates[selectedDateIndex]?.date
86
+ ? selectedSlot.time
87
+ : null;
88
+
89
+ const [confirming, setConfirming] = useState(false);
90
+
91
+ const handleSlotClick = (date: string, time: string) => {
92
+ if (disabled || confirming) return;
93
+ setSelectedSlot({ date, time });
94
+ };
95
+
96
+ const handleConfirm = useCallback(() => {
97
+ if (!selectedSlot || disabled || confirming) return;
98
+ setConfirming(true);
99
+ onSlotSelected(selectedSlot.date, selectedSlot.time);
100
+ }, [selectedSlot, disabled, confirming, onSlotSelected]);
101
+
102
+ const currentDate = data.availableDates[selectedDateIndex];
103
+
104
+ return (
105
+ <div style={cardStyle}>
106
+ {/* Section label */}
107
+ <p
108
+ style={{
109
+ fontSize: '11px',
110
+ fontWeight: '600',
111
+ letterSpacing: '0.06em',
112
+ textTransform: 'uppercase',
113
+ color: textMuted,
114
+ marginBottom: '10px',
115
+ }}
116
+ >
117
+ Select a date
118
+ </p>
119
+
120
+ {/* Date pills row */}
121
+ <div
122
+ style={{
123
+ display: 'flex',
124
+ gap: '6px',
125
+ overflowX: 'auto',
126
+ paddingBottom: '2px',
127
+ scrollbarWidth: 'none',
128
+ msOverflowStyle: 'none',
129
+ marginBottom: '14px',
130
+ }}
131
+ >
132
+ {data.availableDates.map((dateSlot, index) => (
133
+ <button
134
+ key={dateSlot.date}
135
+ type="button"
136
+ onClick={() => !disabled && setSelectedDateIndex(index)}
137
+ style={index === selectedDateIndex ? activePillStyle : inactivePillStyle}
138
+ title={dateSlot.dayName}
139
+ aria-pressed={index === selectedDateIndex}
140
+ disabled={disabled}
141
+ >
142
+ {dateSlot.dayLabel}
143
+ </button>
144
+ ))}
145
+ </div>
146
+
147
+ {/* Time slots grid */}
148
+ {currentDate && (
149
+ <>
150
+ <p
151
+ style={{
152
+ fontSize: '11px',
153
+ fontWeight: '600',
154
+ letterSpacing: '0.06em',
155
+ textTransform: 'uppercase',
156
+ color: textMuted,
157
+ marginBottom: '8px',
158
+ }}
159
+ >
160
+ {currentDate.dayName}
161
+ </p>
162
+ <div
163
+ style={{
164
+ display: 'grid',
165
+ gridTemplateColumns: 'repeat(3, 1fr)',
166
+ gap: '6px',
167
+ marginBottom: '14px',
168
+ }}
169
+ >
170
+ {currentDate.slots.map((time) => {
171
+ const isSelected = activeSlot === time;
172
+ const isConfirmed = selectedSlot?.date === currentDate.date && selectedSlot.time === time;
173
+ return (
174
+ <button
175
+ key={time}
176
+ type="button"
177
+ onClick={() => handleSlotClick(currentDate.date, time)}
178
+ disabled={disabled && !isConfirmed}
179
+ aria-pressed={isSelected}
180
+ style={{
181
+ ...(isSelected ? activePillStyle : inactivePillStyle),
182
+ padding: '7px 8px',
183
+ borderRadius: '8px',
184
+ fontSize: '13px',
185
+ fontWeight: isSelected ? '600' : '400',
186
+ textAlign: 'center',
187
+ opacity: disabled && !isConfirmed ? 0.4 : 1,
188
+ transition: 'opacity 0.15s ease',
189
+ }}
190
+ >
191
+ {time}
192
+ </button>
193
+ );
194
+ })}
195
+ </div>
196
+ </>
197
+ )}
198
+
199
+ {/* Confirm button — only visible when a slot is selected */}
200
+ {selectedSlot && !disabled && (
201
+ <button
202
+ type="button"
203
+ onClick={handleConfirm}
204
+ disabled={confirming}
205
+ style={{
206
+ width: '100%',
207
+ padding: '10px 16px',
208
+ borderRadius: '10px',
209
+ border: 'none',
210
+ backgroundColor: confirming ? `${primaryColor}80` : primaryColor,
211
+ color: '#ffffff',
212
+ fontSize: '13px',
213
+ fontWeight: '600',
214
+ letterSpacing: '0.01em',
215
+ cursor: confirming ? 'default' : 'pointer',
216
+ marginBottom: '12px',
217
+ display: 'flex',
218
+ alignItems: 'center',
219
+ justifyContent: 'center',
220
+ gap: '6px',
221
+ transition: 'background-color 0.15s ease',
222
+ }}
223
+ >
224
+ <Calendar size={14} aria-hidden="true" />
225
+ {confirming ? 'Booking...' : `Book ${selectedSlot.time} on ${data.availableDates.find(d => d.date === selectedSlot.date)?.dayLabel ?? selectedSlot.date}`}
226
+ </button>
227
+ )}
228
+
229
+ {/* Footer: timezone + duration */}
230
+ <div
231
+ style={{
232
+ display: 'flex',
233
+ alignItems: 'center',
234
+ justifyContent: 'space-between',
235
+ gap: '8px',
236
+ }}
237
+ >
238
+ <div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
239
+ <Globe size={11} color={textMuted} aria-hidden="true" />
240
+ <span style={{ fontSize: '11px', color: textMuted, letterSpacing: '0.01em' }}>
241
+ {data.timezone}
242
+ </span>
243
+ </div>
244
+ <div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
245
+ <Clock size={11} color={textMuted} aria-hidden="true" />
246
+ <span style={{ fontSize: '11px', color: textMuted, letterSpacing: '0.01em' }}>
247
+ {data.meetingDuration} min
248
+ </span>
249
+ </div>
250
+ </div>
251
+ </div>
252
+ );
253
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react';
2
- import { XcelsiorSymbol, XcelsiorAvatar } from './BrandIcons';
2
+ import { XcelsiorSymbol, XcelsiorAvatar, ChatBubbleIcon } from './BrandIcons';
3
3
 
4
4
  const meta: Meta<typeof XcelsiorSymbol> = {
5
5
  title: 'Brand/Icons',
@@ -70,6 +70,37 @@ export const AvatarSizes: StoryObj = {
70
70
  ),
71
71
  };
72
72
 
73
+ /** ChatBubbleIcon — FAB robot icon at various sizes */
74
+ export const ChatBubbleSizes: StoryObj = {
75
+ render: () => (
76
+ <div className="flex flex-col gap-10 p-8">
77
+ {/* Actual FAB size */}
78
+ <div>
79
+ <h3 className="text-sm font-semibold text-gray-400 mb-4">Actual FAB (36px icon in 64px circle)</h3>
80
+ <div className="rounded-full flex items-center justify-center" style={{ width: 64, height: 64, background: 'linear-gradient(135deg, #337eff, #005eff)', boxShadow: '0 4px 24px 0 rgba(51,126,255,0.5)' }}>
81
+ <ChatBubbleIcon size={36} color="white" />
82
+ </div>
83
+ </div>
84
+ {/* Enlarged */}
85
+ <div>
86
+ <h3 className="text-sm font-semibold text-gray-400 mb-4">Enlarged 2x</h3>
87
+ <div className="rounded-full flex items-center justify-center" style={{ width: 128, height: 128, background: 'linear-gradient(135deg, #337eff, #005eff)' }}>
88
+ <ChatBubbleIcon size={72} color="white" />
89
+ </div>
90
+ </div>
91
+ {/* On dark page */}
92
+ <div>
93
+ <h3 className="text-sm font-semibold text-gray-400 mb-4">On dark page</h3>
94
+ <div className="flex items-center gap-10 p-10 rounded-lg" style={{ background: 'linear-gradient(180deg, #0a0e27, #111633)' }}>
95
+ <div className="rounded-full flex items-center justify-center" style={{ width: 64, height: 64, background: 'linear-gradient(135deg, #337eff, #005eff)', boxShadow: '0 4px 24px 0 rgba(51,126,255,0.5), 0 8px 32px -4px rgba(0,0,0,0.3)' }}>
96
+ <ChatBubbleIcon size={36} color="white" />
97
+ </div>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ ),
102
+ };
103
+
73
104
  /** Avatar in context — message list style */
74
105
  export const AvatarInContext: StoryObj = {
75
106
  render: () => (
@@ -40,25 +40,29 @@ export function XcelsiorSymbol({ size = 24, color = 'white', className = '', sty
40
40
  }
41
41
 
42
42
  /**
43
- * Chat bubble icon for the FAB launcher button.
44
- * Instantly recognizable as a chat trigger.
43
+ * Expressive robot icon for the FAB launcher button.
44
+ * Friendly robot with cyan antenna glow, ear nubs, eyes with highlights, and smile.
45
45
  */
46
- export function ChatBubbleIcon({ size = 28, color = 'white', className = '', style }: BrandIconProps) {
46
+ export function ChatBubbleIcon({ size = 36, color = 'white', className = '', style }: BrandIconProps) {
47
47
  return (
48
- <svg
49
- width={size}
50
- height={size}
51
- viewBox="0 0 24 24"
52
- fill="none"
53
- stroke={color}
54
- strokeWidth={2}
55
- strokeLinecap="round"
56
- strokeLinejoin="round"
57
- className={className}
58
- style={style}
59
- aria-hidden="true"
60
- >
61
- <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
48
+ <svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className} style={style} aria-hidden="true">
49
+ {/* Antenna with glow ring — cyan */}
50
+ <circle cx="16" cy="3.5" r="3" fill="#67e8f9" opacity={0.4} />
51
+ <circle cx="16" cy="3.5" r="1.5" fill="#67e8f9" />
52
+ <rect x="15" y="4.5" width="2" height="4" rx="1" fill={color} />
53
+ {/* Ear nubs */}
54
+ <rect x="1" y="15" width="4" height="6" rx="2" fill={color} opacity={0.5} />
55
+ <rect x="27" y="15" width="4" height="6" rx="2" fill={color} opacity={0.5} />
56
+ {/* Head — rounded rectangle */}
57
+ <rect x="5" y="8.5" width="22" height="19" rx="5" fill={color} />
58
+ {/* Eyes — dark cutouts (blue to show through on gradient bg) */}
59
+ <circle cx="12" cy="16.5" r="3" fill="#1a4fd0" />
60
+ <circle cx="20" cy="16.5" r="3" fill="#1a4fd0" />
61
+ {/* Eye highlights */}
62
+ <circle cx="13" cy="15.5" r="1.2" fill={color} />
63
+ <circle cx="21" cy="15.5" r="1.2" fill={color} />
64
+ {/* Smile */}
65
+ <path d="M13 22.5q3 2.5 6 0" stroke="#1a4fd0" strokeWidth="1.5" strokeLinecap="round" />
62
66
  </svg>
63
67
  );
64
68
  }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { ChatWidget } from './ChatWidget';
3
3
  import { PreChatForm } from './PreChatForm';
4
4
  import { ChatBubbleIcon } from './BrandIcons';
@@ -45,73 +45,44 @@ export function Chat({
45
45
  const [userInfo, setUserInfo] = useState<IUser | null>(null);
46
46
  const [conversationId, setConversationId] = useState<string>('');
47
47
  const [isLoading, setIsLoading] = useState(true);
48
- const [isAnimating, setIsAnimating] = useState(false);
49
- const [showWidget, setShowWidget] = useState(false);
50
48
 
51
49
  const identityMode = config.identityCollection || 'progressive';
52
- const { position, isDragging, showHint, handlers } = useDraggablePosition(config.position);
50
+ const { position, isDragging, showHint, containerRef, shouldSuppressClick, handlers } = useDraggablePosition(config.position);
51
+ const sessionInitializedRef = useRef(false);
53
52
 
54
- const { currentState, setState: setStateRaw } = useChatWidgetState({
53
+ const { currentState, setState } = useChatWidgetState({
55
54
  state,
56
55
  defaultState,
57
56
  onStateChange,
58
57
  });
59
58
 
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]);
59
+ // Extract primitives from config for stable dependency array
60
+ const configConversationId = config.conversationId;
61
+ const configUserEmail = config.currentUser?.email;
62
+ const configUserName = config.currentUser?.name;
63
+ const configUserAvatar = config.currentUser?.avatar;
64
+ const configUserStatus = config.currentUser?.status;
98
65
 
99
66
  // Initialize session
100
67
  useEffect(() => {
68
+ // Guard: only run once unless identity actually changes
69
+ if (sessionInitializedRef.current) return;
70
+
101
71
  const initializeSession = () => {
102
72
  try {
103
- if (config.currentUser?.email && config.currentUser?.name) {
104
- const convId = config.conversationId || generateSessionId();
73
+ if (configUserEmail && configUserName) {
74
+ const convId = configConversationId || generateSessionId();
105
75
  const user: IUser = {
106
- name: config.currentUser.name,
107
- email: config.currentUser.email,
108
- avatar: config.currentUser.avatar,
76
+ name: configUserName,
77
+ email: configUserEmail,
78
+ avatar: configUserAvatar,
109
79
  type: 'customer',
110
- status: config.currentUser.status,
80
+ status: configUserStatus,
111
81
  };
112
82
  setUserInfo(user);
113
83
  setConversationId(convId);
114
84
  setIsLoading(false);
85
+ sessionInitializedRef.current = true;
115
86
  return;
116
87
  }
117
88
 
@@ -130,26 +101,62 @@ export function Chat({
130
101
  setUserInfo(user);
131
102
  setConversationId(storedData.conversationId);
132
103
  setIsLoading(false);
104
+ sessionInitializedRef.current = true;
133
105
  return;
134
106
  }
135
107
  }
136
108
 
137
- const convId = config.conversationId || generateSessionId();
109
+ const convId = configConversationId || generateSessionId();
138
110
  setConversationId(convId);
139
111
 
140
112
  if (identityMode === 'progressive' || identityMode === 'none') {
141
- setUserInfo(null);
113
+ // Resolve the anonymous user now so we can persist it alongside the conversationId.
114
+ // getAnonymousUser (in ChatWidget) creates/reads the anon-id from localStorage,
115
+ // but the conversationId was never persisted — fix that here.
116
+ let anonId: string;
117
+ try {
118
+ const stored = localStorage.getItem('xcelsior-chat-anon-id');
119
+ if (stored) {
120
+ anonId = stored;
121
+ } else {
122
+ anonId = `anon-${crypto.randomUUID?.() || Math.random().toString(36).slice(2)}`;
123
+ localStorage.setItem('xcelsior-chat-anon-id', anonId);
124
+ }
125
+ } catch {
126
+ anonId = `anon-${Math.random().toString(36).slice(2)}`;
127
+ }
128
+
129
+ const anonEmail = `${anonId}@anonymous.xcelsior.co`;
130
+ const anonUser: IUser = { name: 'Visitor', email: anonEmail, type: 'customer', status: 'online' };
131
+ setUserInfo(anonUser);
132
+
133
+ // Persist so the session survives page refresh
134
+ try {
135
+ localStorage.setItem(
136
+ `${storageKeyPrefix}_user`,
137
+ JSON.stringify({
138
+ name: anonUser.name,
139
+ email: anonUser.email,
140
+ conversationId: convId,
141
+ timestamp: Date.now(),
142
+ } satisfies StoredUserData),
143
+ );
144
+ } catch {
145
+ // Storage unavailable
146
+ }
142
147
  }
148
+ sessionInitializedRef.current = true;
143
149
  } catch (error) {
144
150
  console.error('Error initializing chat session:', error);
145
- setConversationId(config.conversationId || generateSessionId());
151
+ setConversationId(configConversationId || generateSessionId());
152
+ sessionInitializedRef.current = true;
146
153
  } finally {
147
154
  setIsLoading(false);
148
155
  }
149
156
  };
150
157
 
151
158
  initializeSession();
152
- }, [config, storageKeyPrefix, identityMode]);
159
+ }, [configConversationId, configUserEmail, configUserName, configUserAvatar, configUserStatus, storageKeyPrefix, identityMode]);
153
160
 
154
161
  const handlePreChatSubmit = useCallback(
155
162
  (name: string, email: string) => {
@@ -186,15 +193,16 @@ export function Chat({
186
193
  // FAB button — only show when minimized
187
194
  if (currentState === 'minimized') {
188
195
  return (
189
- <div className={`fixed bottom-5 ${positionClass} z-50 ${className}`}>
196
+ <div
197
+ ref={containerRef}
198
+ className={`fixed bottom-5 ${positionClass} z-50 ${className}`}
199
+ >
190
200
  <button
191
201
  type="button"
192
- onClick={() => setState('open')}
193
- className={`group relative rounded-full text-white transition-all duration-300 flex items-center justify-center touch-none select-none ${
202
+ onClick={() => { if (!shouldSuppressClick()) setState('open'); }}
203
+ className={`group relative rounded-full text-white flex items-center justify-center touch-none select-none ${
194
204
  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)'; }}
205
+ } ${isDragging ? 'cursor-grabbing scale-110' : 'cursor-grab hover:scale-[1.08] transition-transform duration-300'}`}
198
206
  style={{
199
207
  width: 64,
200
208
  height: 64,
@@ -208,7 +216,7 @@ export function Chat({
208
216
  aria-label="Open chat"
209
217
  {...handlers}
210
218
  >
211
- <ChatBubbleIcon size={28} color="white" className="pointer-events-none" />
219
+ <ChatBubbleIcon size={36} className="pointer-events-none" />
212
220
 
213
221
  {/* Pulse ring — subtle blue glow */}
214
222
  <span
@@ -245,29 +253,16 @@ export function Chat({
245
253
  currentUser: userInfo || undefined,
246
254
  };
247
255
 
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
-
256
+ // Render ChatWidget directly — no wrapper div.
257
+ // A wrapper with CSS `transform` (even identity) creates a new containing block,
258
+ // which breaks `position: fixed` on the ChatWidget.
262
259
  return (
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>
260
+ <ChatWidget
261
+ config={fullConfig}
262
+ className={className}
263
+ onClose={() => setState('closed')}
264
+ onMinimize={() => setState('minimized')}
265
+ resolvedPosition={position}
266
+ />
272
267
  );
273
268
  }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect } from 'react';
1
+ import { useCallback, useEffect, useRef } from 'react';
2
2
  import { useWebSocket } from '../hooks/useWebSocket';
3
3
  import { useMessages } from '../hooks/useMessages';
4
4
  import { useFileUpload } from '../hooks/useFileUpload';
@@ -76,6 +76,10 @@ export function ChatWidget({
76
76
  enabled: !isFullPage,
77
77
  });
78
78
 
79
+ // Guard against rapid duplicate sends (e.g. double-clicking quick action buttons
80
+ // before React re-renders to remove them). Tracks the last sent content + timestamp.
81
+ const lastSentRef = useRef<{ content: string; time: number } | null>(null);
82
+
79
83
  const handleSendMessage = useCallback(
80
84
  (content: string) => {
81
85
  if (!websocket.isConnected) {
@@ -83,8 +87,19 @@ export function ChatWidget({
83
87
  return;
84
88
  }
85
89
 
90
+ // Deduplicate rapid identical sends within 1 second
91
+ const now = Date.now();
92
+ if (
93
+ lastSentRef.current &&
94
+ lastSentRef.current.content === content &&
95
+ now - lastSentRef.current.time < 1000
96
+ ) {
97
+ return;
98
+ }
99
+ lastSentRef.current = { content, time: now };
100
+
86
101
  const optimisticMessage: IMessage = {
87
- id: `temp-${Date.now()}`,
102
+ id: `temp-${now}`,
88
103
  conversationId: config.conversationId || '',
89
104
  senderId: effectiveUser.email,
90
105
  senderType: effectiveUser.type,
@@ -107,6 +122,18 @@ export function ChatWidget({
107
122
  [websocket, config, addMessage, effectiveUser]
108
123
  );
109
124
 
125
+ const handleBookingSlotSelected = useCallback(
126
+ (date: string, time: string, _messageId: string) => {
127
+ if (!websocket.isConnected) return;
128
+ websocket.sendMessage('bookSlot', {
129
+ conversationId: config.conversationId,
130
+ date,
131
+ time,
132
+ });
133
+ },
134
+ [websocket, config]
135
+ );
136
+
110
137
  const handleTyping = useCallback(
111
138
  (isTyping: boolean) => {
112
139
  if (!websocket.isConnected || config.enableTypingIndicator === false) return;
@@ -147,7 +174,6 @@ export function ChatWidget({
147
174
  const containerStyle: React.CSSProperties = isFullPage
148
175
  ? { backgroundColor: bgColor, color: textColor }
149
176
  : {
150
- position: 'relative',
151
177
  width,
152
178
  height,
153
179
  maxHeight: 'calc(100vh - 100px)',
@@ -304,6 +330,7 @@ export function ChatWidget({
304
330
  theme={config.theme}
305
331
  onQuickAction={handleSendMessage}
306
332
  isBotThinking={isBotThinking}
333
+ onBookingSlotSelected={handleBookingSlotSelected}
307
334
  />
308
335
  )}
309
336