@xcelsior/ui-chat 2.0.4 → 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.
- package/dist/index.d.mts +58 -5
- package/dist/index.d.ts +58 -5
- package/dist/index.js +1042 -427
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1009 -397
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/components/BookingCancelledCard.tsx +103 -0
- package/src/components/BookingCards.stories.tsx +102 -0
- package/src/components/BookingConfirmationCard.tsx +170 -0
- package/src/components/BookingSlotPicker.stories.tsx +87 -0
- package/src/components/BookingSlotPicker.tsx +253 -0
- package/src/components/BrandIcons.stories.tsx +32 -1
- package/src/components/BrandIcons.tsx +21 -17
- package/src/components/Chat.tsx +43 -9
- package/src/components/ChatWidget.tsx +30 -2
- package/src/components/MessageItem.tsx +83 -72
- package/src/components/MessageList.tsx +4 -0
- package/src/hooks/useDraggablePosition.ts +147 -42
- package/src/hooks/useMessages.ts +106 -53
- package/src/hooks/useWebSocket.ts +17 -4
- package/src/index.tsx +11 -0
- package/src/types.ts +39 -2
- package/src/utils/api.ts +1 -0
- package/storybook-static/assets/BookingCancelledCard-XHuB-Ebp.js +31 -0
- package/storybook-static/assets/BookingCards.stories-DfJ482RS.js +66 -0
- package/storybook-static/assets/BookingSlotPicker-BkfssueW.js +1 -0
- package/storybook-static/assets/BookingSlotPicker.stories-fYlg1zLg.js +50 -0
- package/storybook-static/assets/BrandIcons-BsRAdWzL.js +4 -0
- package/storybook-static/assets/BrandIcons.stories-C6gBovfU.js +106 -0
- package/storybook-static/assets/Chat.stories-BrR7LHsz.js +830 -0
- package/storybook-static/assets/{Color-YHDXOIA2-CSuNIR0a.js → Color-YHDXOIA2-azE51u2m.js} +1 -1
- package/storybook-static/assets/{DocsRenderer-CFRXHY34-dpuOKTQp.js → DocsRenderer-CFRXHY34-jTmzKIDk.js} +3 -3
- package/storybook-static/assets/MessageItem-pEOwuLyh.js +34 -0
- package/storybook-static/assets/{MessageItem.stories-CsxqSqu-.js → MessageItem.stories-Cs5Vtkle.js} +2 -2
- package/storybook-static/assets/{entry-preview-C_-WO6GJ.js → entry-preview-vcpiajAT.js} +1 -1
- package/storybook-static/assets/globe-BtMvkLMD.js +31 -0
- package/storybook-static/assets/{iframe-BXTccXxS.js → iframe-Cx1n-SeE.js} +2 -2
- package/storybook-static/assets/{preview-Cyx3pE7Q.js → preview-Do3b3dZv.js} +2 -2
- package/storybook-static/iframe.html +1 -1
- package/storybook-static/index.json +1 -1
- package/storybook-static/project.json +1 -1
- package/storybook-static/assets/BrandIcons-Cjy5INAp.js +0 -4
- package/storybook-static/assets/BrandIcons.stories-BeVC6svr.js +0 -64
- package/storybook-static/assets/Chat.stories-BkbpOOSG.js +0 -830
- package/storybook-static/assets/MessageItem-Dlb6dSKL.js +0 -14
|
@@ -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
|
-
*
|
|
44
|
-
*
|
|
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 =
|
|
46
|
+
export function ChatBubbleIcon({ size = 36, color = 'white', className = '', style }: BrandIconProps) {
|
|
47
47
|
return (
|
|
48
|
-
<svg
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
}
|
package/src/components/Chat.tsx
CHANGED
|
@@ -47,7 +47,7 @@ export function Chat({
|
|
|
47
47
|
const [isLoading, setIsLoading] = useState(true);
|
|
48
48
|
|
|
49
49
|
const identityMode = config.identityCollection || 'progressive';
|
|
50
|
-
const { position, isDragging, showHint, handlers } = useDraggablePosition(config.position);
|
|
50
|
+
const { position, isDragging, showHint, containerRef, shouldSuppressClick, handlers } = useDraggablePosition(config.position);
|
|
51
51
|
const sessionInitializedRef = useRef(false);
|
|
52
52
|
|
|
53
53
|
const { currentState, setState } = useChatWidgetState({
|
|
@@ -110,7 +110,40 @@ export function Chat({
|
|
|
110
110
|
setConversationId(convId);
|
|
111
111
|
|
|
112
112
|
if (identityMode === 'progressive' || identityMode === 'none') {
|
|
113
|
-
|
|
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
|
+
}
|
|
114
147
|
}
|
|
115
148
|
sessionInitializedRef.current = true;
|
|
116
149
|
} catch (error) {
|
|
@@ -160,15 +193,16 @@ export function Chat({
|
|
|
160
193
|
// FAB button — only show when minimized
|
|
161
194
|
if (currentState === 'minimized') {
|
|
162
195
|
return (
|
|
163
|
-
<div
|
|
196
|
+
<div
|
|
197
|
+
ref={containerRef}
|
|
198
|
+
className={`fixed bottom-5 ${positionClass} z-50 ${className}`}
|
|
199
|
+
>
|
|
164
200
|
<button
|
|
165
201
|
type="button"
|
|
166
|
-
onClick={() => setState('open')}
|
|
167
|
-
className={`group relative rounded-full text-white
|
|
202
|
+
onClick={() => { if (!shouldSuppressClick()) setState('open'); }}
|
|
203
|
+
className={`group relative rounded-full text-white flex items-center justify-center touch-none select-none ${
|
|
168
204
|
showHint ? 'animate-bounce' : ''
|
|
169
|
-
} ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
|
170
|
-
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.08)'; }}
|
|
171
|
-
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'}`}
|
|
172
206
|
style={{
|
|
173
207
|
width: 64,
|
|
174
208
|
height: 64,
|
|
@@ -182,7 +216,7 @@ export function Chat({
|
|
|
182
216
|
aria-label="Open chat"
|
|
183
217
|
{...handlers}
|
|
184
218
|
>
|
|
185
|
-
<ChatBubbleIcon size={
|
|
219
|
+
<ChatBubbleIcon size={36} className="pointer-events-none" />
|
|
186
220
|
|
|
187
221
|
{/* Pulse ring — subtle blue glow */}
|
|
188
222
|
<span
|
|
@@ -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-${
|
|
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;
|
|
@@ -303,6 +330,7 @@ export function ChatWidget({
|
|
|
303
330
|
theme={config.theme}
|
|
304
331
|
onQuickAction={handleSendMessage}
|
|
305
332
|
isBotThinking={isBotThinking}
|
|
333
|
+
onBookingSlotSelected={handleBookingSlotSelected}
|
|
306
334
|
/>
|
|
307
335
|
)}
|
|
308
336
|
|