@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.
- package/.storybook/preview.tsx +2 -1
- package/dist/index.d.mts +58 -5
- package/dist/index.d.ts +58 -5
- package/dist/index.js +1073 -473
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1060 -463
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -2
- 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 +78 -83
- package/src/components/ChatWidget.tsx +30 -3
- 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 +119 -45
- 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-BMnd3YrF.js → Color-YHDXOIA2-azE51u2m.js} +1 -1
- package/storybook-static/assets/{DocsRenderer-CFRXHY34-i_W8iCu9.js → DocsRenderer-CFRXHY34-jTmzKIDk.js} +3 -3
- package/storybook-static/assets/MessageItem-pEOwuLyh.js +34 -0
- package/storybook-static/assets/MessageItem.stories-Cs5Vtkle.js +422 -0
- package/storybook-static/assets/{entry-preview-oDnntGcx.js → entry-preview-vcpiajAT.js} +1 -1
- package/storybook-static/assets/globe-BtMvkLMD.js +31 -0
- package/storybook-static/assets/{iframe-CGBtu2Se.js → iframe-Cx1n-SeE.js} +2 -2
- package/storybook-static/assets/preview-B8y-wc-n.css +1 -0
- package/storybook-static/assets/preview-CC4t7T7W.js +1 -0
- package/storybook-static/assets/{preview-BRpahs9B.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/tsconfig.json +4 -0
- 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-J_Yp51wU.js +0 -803
- package/storybook-static/assets/MessageItem-DAaKZ9s9.js +0 -14
- package/storybook-static/assets/MessageItem.stories-Ckr1_scc.js +0 -255
- package/storybook-static/assets/ToastContext-Bty1K7ya.js +0 -1
- package/storybook-static/assets/preview-DUOvJmsz.js +0 -1
- 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
|
-
*
|
|
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
|
@@ -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
|
|
53
|
+
const { currentState, setState } = useChatWidgetState({
|
|
55
54
|
state,
|
|
56
55
|
defaultState,
|
|
57
56
|
onStateChange,
|
|
58
57
|
});
|
|
59
58
|
|
|
60
|
-
//
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 (
|
|
104
|
-
const convId =
|
|
73
|
+
if (configUserEmail && configUserName) {
|
|
74
|
+
const convId = configConversationId || generateSessionId();
|
|
105
75
|
const user: IUser = {
|
|
106
|
-
name:
|
|
107
|
-
email:
|
|
108
|
-
avatar:
|
|
76
|
+
name: configUserName,
|
|
77
|
+
email: configUserEmail,
|
|
78
|
+
avatar: configUserAvatar,
|
|
109
79
|
type: 'customer',
|
|
110
|
-
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 =
|
|
109
|
+
const convId = configConversationId || generateSessionId();
|
|
138
110
|
setConversationId(convId);
|
|
139
111
|
|
|
140
112
|
if (identityMode === 'progressive' || identityMode === 'none') {
|
|
141
|
-
|
|
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(
|
|
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
|
-
}, [
|
|
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
|
|
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
|
|
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={
|
|
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
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
<
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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-${
|
|
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
|
|