@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
@@ -1,7 +1,11 @@
1
1
  import { formatDistanceToNow } from 'date-fns';
2
- import type { IMessage, IUser, IChatTheme } from '../types';
2
+ import { Check, CheckCheck, Headphones, Paperclip } from 'lucide-react';
3
+ import type { IMessage, IUser, IChatTheme, IBookingData, IBookingConfirmationData, IBookingCancelledData } from '../types';
3
4
  import { XcelsiorAvatar } from './BrandIcons';
4
5
  import { MarkdownMessage } from './MarkdownMessage';
6
+ import { BookingSlotPicker } from './BookingSlotPicker';
7
+ import { BookingConfirmationCard } from './BookingConfirmationCard';
8
+ import { BookingCancelledCard } from './BookingCancelledCard';
5
9
 
6
10
  interface MessageItemProps {
7
11
  message: IMessage;
@@ -9,6 +13,8 @@ interface MessageItemProps {
9
13
  showAvatar?: boolean;
10
14
  showTimestamp?: boolean;
11
15
  theme?: IChatTheme;
16
+ /** Called when visitor selects a booking slot — sends their choice as a chat message */
17
+ onBookingSlotSelected?: (date: string, time: string, messageId: string) => void;
12
18
  }
13
19
 
14
20
  export function MessageItem({
@@ -17,6 +23,7 @@ export function MessageItem({
17
23
  showAvatar = true,
18
24
  showTimestamp = true,
19
25
  theme,
26
+ onBookingSlotSelected,
20
27
  }: MessageItemProps) {
21
28
  const isOwnMessage = message.senderType === currentUser.type;
22
29
  const isSystemMessage = message.senderType === 'system';
@@ -38,6 +45,73 @@ export function MessageItem({
38
45
  const textColor = theme?.text || (isLightTheme ? '#1a1a2e' : '#f7f7f8');
39
46
  const textMuted = theme?.textMuted || (isLightTheme ? 'rgba(0,0,0,0.35)' : 'rgba(247,247,248,0.35)');
40
47
 
48
+ // Booking messages — render card components directly (no bubble wrapper)
49
+ if (
50
+ message.messageType === 'booking_slots' ||
51
+ message.messageType === 'booking_confirmation' ||
52
+ message.messageType === 'booking_cancelled'
53
+ ) {
54
+ return (
55
+ <div className="flex gap-2.5 mb-3 flex-row">
56
+ {/* Avatar placeholder */}
57
+ {showAvatar && (
58
+ <div className="flex-shrink-0 mt-auto mb-5">
59
+ <XcelsiorAvatar size={28} />
60
+ </div>
61
+ )}
62
+ <div className="flex flex-col max-w-[85%] items-start">
63
+ {/* Sender label */}
64
+ <span
65
+ className="mb-1 px-1 font-medium"
66
+ style={{
67
+ color: isLightTheme ? 'rgba(0,0,0,0.45)' : 'rgba(247,247,248,0.4)',
68
+ fontSize: '11px',
69
+ letterSpacing: '0.019em',
70
+ }}
71
+ >
72
+ AI Assistant
73
+ </span>
74
+ {message.messageType === 'booking_slots' && (
75
+ <BookingSlotPicker
76
+ data={message.metadata?.bookingData as IBookingData}
77
+ theme={theme}
78
+ onSlotSelected={(date, time) => {
79
+ onBookingSlotSelected?.(date, time, message.id);
80
+ }}
81
+ disabled={!!message.metadata?.slotSelected}
82
+ />
83
+ )}
84
+ {message.messageType === 'booking_confirmation' && (
85
+ <BookingConfirmationCard
86
+ data={message.metadata?.bookingConfirmation as IBookingConfirmationData}
87
+ theme={theme}
88
+ />
89
+ )}
90
+ {message.messageType === 'booking_cancelled' && (
91
+ <BookingCancelledCard
92
+ data={message.metadata?.bookingCancelled as IBookingCancelledData}
93
+ theme={theme}
94
+ />
95
+ )}
96
+ {/* Timestamp */}
97
+ {showTimestamp && (
98
+ <div className="flex items-center gap-1.5 mt-1 px-1">
99
+ <span
100
+ style={{
101
+ fontSize: '11px',
102
+ letterSpacing: '0.019em',
103
+ color: textMuted,
104
+ }}
105
+ >
106
+ {formatDistanceToNow(new Date(message.createdAt), { addSuffix: true })}
107
+ </span>
108
+ </div>
109
+ )}
110
+ </div>
111
+ </div>
112
+ );
113
+ }
114
+
41
115
  // System messages — centered pill with ultra-subtle surface
42
116
  if (isSystemMessage) {
43
117
  return (
@@ -121,21 +195,12 @@ export function MessageItem({
121
195
  : 'inset 0 0 0 0.5px rgba(255,255,255,0.1)',
122
196
  }}
123
197
  >
124
- <svg
125
- width="14"
126
- height="14"
127
- viewBox="0 0 24 24"
128
- fill="none"
198
+ <Headphones
199
+ size={14}
129
200
  stroke={isLightTheme ? primaryColor : 'white'}
130
- strokeWidth="2"
131
- strokeLinecap="round"
132
- strokeLinejoin="round"
201
+ strokeWidth={2}
133
202
  aria-hidden="true"
134
- >
135
- <title>Agent</title>
136
- <path d="M3 18v-6a9 9 0 0 1 18 0v6" />
137
- <path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z" />
138
- </svg>
203
+ />
139
204
  </div>
140
205
  )}
141
206
  </div>
@@ -194,20 +259,7 @@ export function MessageItem({
194
259
  )}
195
260
  {message.messageType === 'file' && (
196
261
  <div className="flex items-center gap-2">
197
- <svg
198
- width="18"
199
- height="18"
200
- viewBox="0 0 24 24"
201
- fill="none"
202
- stroke="currentColor"
203
- strokeWidth="1.75"
204
- strokeLinecap="round"
205
- strokeLinejoin="round"
206
- aria-hidden="true"
207
- >
208
- <title>File</title>
209
- <path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
210
- </svg>
262
+ <Paperclip size={18} strokeWidth={1.75} aria-hidden="true" />
211
263
  <a
212
264
  href={message.content}
213
265
  target="_blank"
@@ -245,54 +297,13 @@ export function MessageItem({
245
297
  {isOwnMessage && message.status && (
246
298
  <span style={{ color: textMuted }}>
247
299
  {message.status === 'sent' && (
248
- <svg
249
- width="14"
250
- height="14"
251
- viewBox="0 0 24 24"
252
- fill="none"
253
- stroke="currentColor"
254
- strokeWidth="2.5"
255
- strokeLinecap="round"
256
- strokeLinejoin="round"
257
- aria-hidden="true"
258
- >
259
- <title>Sent</title>
260
- <polyline points="20 6 9 17 4 12" />
261
- </svg>
300
+ <Check size={14} strokeWidth={2.5} aria-hidden="true" />
262
301
  )}
263
302
  {message.status === 'delivered' && (
264
- <svg
265
- width="14"
266
- height="14"
267
- viewBox="0 0 24 24"
268
- fill="none"
269
- stroke="currentColor"
270
- strokeWidth="2.5"
271
- strokeLinecap="round"
272
- strokeLinejoin="round"
273
- aria-hidden="true"
274
- >
275
- <title>Delivered</title>
276
- <polyline points="18 6 7 17 2 12" />
277
- <polyline points="22 6 11 17" />
278
- </svg>
303
+ <CheckCheck size={14} strokeWidth={2.5} aria-hidden="true" />
279
304
  )}
280
305
  {message.status === 'read' && (
281
- <svg
282
- width="14"
283
- height="14"
284
- viewBox="0 0 24 24"
285
- fill="none"
286
- stroke={primaryColor}
287
- strokeWidth="2.5"
288
- strokeLinecap="round"
289
- strokeLinejoin="round"
290
- aria-hidden="true"
291
- >
292
- <title>Read</title>
293
- <polyline points="18 6 7 17 2 12" />
294
- <polyline points="22 6 11 17" />
295
- </svg>
306
+ <CheckCheck size={14} strokeWidth={2.5} stroke={primaryColor} aria-hidden="true" />
296
307
  )}
297
308
  </span>
298
309
  )}
@@ -23,6 +23,8 @@ interface MessageListProps {
23
23
  onQuickAction?: (text: string) => void;
24
24
  /** True when user sent a message and bot response hasn't arrived yet */
25
25
  isBotThinking?: boolean;
26
+ /** Called when visitor selects a booking slot */
27
+ onBookingSlotSelected?: (date: string, time: string, messageId: string) => void;
26
28
  }
27
29
 
28
30
  export function MessageList({
@@ -38,6 +40,7 @@ export function MessageList({
38
40
  theme,
39
41
  onQuickAction,
40
42
  isBotThinking = false,
43
+ onBookingSlotSelected,
41
44
  }: MessageListProps) {
42
45
  const messagesEndRef = useRef<HTMLDivElement>(null);
43
46
  const containerRef = useRef<HTMLDivElement>(null);
@@ -250,6 +253,7 @@ export function MessageList({
250
253
  showAvatar={true}
251
254
  showTimestamp={true}
252
255
  theme={theme}
256
+ onBookingSlotSelected={onBookingSlotSelected}
253
257
  />
254
258
  ))}
255
259
 
@@ -2,35 +2,50 @@ import { useState, useCallback, useRef, useEffect } from 'react';
2
2
  import type { WidgetPosition } from '../types';
3
3
 
4
4
  const STORAGE_KEY = 'xcelsior-chat-position';
5
+ const DRAG_THRESHOLD = 5;
6
+ const FAB_SIZE = 64;
7
+ const MARGIN = 16;
8
+ const BOTTOM = 20;
9
+ const VELOCITY_THRESHOLD = 0.4;
10
+ const SETTLE_DURATION = 400;
11
+ const SETTLE_EASING = 'cubic-bezier(0.2, 0.9, 0.3, 1.1)';
5
12
 
6
13
  function getStoredPosition(): 'left' | 'right' {
7
14
  try {
8
15
  const stored = localStorage.getItem(STORAGE_KEY);
9
16
  if (stored === 'left' || stored === 'right') return stored;
10
- } catch {
11
- // localStorage not available
12
- }
17
+ } catch { /* */ }
13
18
  return 'right';
14
19
  }
15
20
 
16
- function storePosition(position: 'left' | 'right') {
17
- try {
18
- localStorage.setItem(STORAGE_KEY, position);
19
- } catch {
20
- // localStorage not available
21
- }
21
+ function storePosition(pos: 'left' | 'right') {
22
+ try { localStorage.setItem(STORAGE_KEY, pos); } catch { /* */ }
23
+ }
24
+
25
+ function restingX(side: 'left' | 'right'): number {
26
+ return side === 'left' ? MARGIN : window.innerWidth - MARGIN - FAB_SIZE;
27
+ }
28
+
29
+ function restingY(): number {
30
+ return window.innerHeight - BOTTOM - FAB_SIZE;
22
31
  }
23
32
 
24
33
  export function useDraggablePosition(configPosition: WidgetPosition = 'auto') {
25
34
  const resolvedDefault = configPosition === 'auto' ? getStoredPosition() : configPosition;
26
35
  const [position, setPosition] = useState<'left' | 'right'>(resolvedDefault);
36
+ const positionRef = useRef(position);
37
+ positionRef.current = position;
38
+
27
39
  const [isDragging, setIsDragging] = useState(false);
28
- const dragStartX = useRef(0);
29
- const fabRef = useRef<HTMLButtonElement>(null);
40
+ const containerRef = useRef<HTMLDivElement>(null);
41
+ const draggingRef = useRef(false);
42
+ const didDragRef = useRef(false);
43
+ const startPointer = useRef({ x: 0, y: 0 });
44
+ const startRect = useRef({ x: 0, y: 0 });
45
+ const samples = useRef<Array<{ x: number; t: number }>>([]);
30
46
  const hasShownHint = useRef(false);
31
47
  const [showHint, setShowHint] = useState(false);
32
48
 
33
- // Show bounce hint on first visit
34
49
  useEffect(() => {
35
50
  try {
36
51
  const hasVisited = localStorage.getItem('xcelsior-chat-hint-shown');
@@ -43,49 +58,139 @@ export function useDraggablePosition(configPosition: WidgetPosition = 'auto') {
43
58
  }, 2000);
44
59
  return () => clearTimeout(timer);
45
60
  }
46
- } catch {
47
- // localStorage not available
48
- }
61
+ } catch { /* */ }
49
62
  }, []);
50
63
 
51
- const handlePointerDown = useCallback((e: React.PointerEvent) => {
52
- dragStartX.current = e.clientX;
53
- setIsDragging(true);
54
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
64
+ useEffect(() => {
65
+ const handleMove = (e: PointerEvent) => {
66
+ if (!draggingRef.current) return;
67
+ const el = containerRef.current;
68
+ if (!el) return;
69
+
70
+ const dx = e.clientX - startPointer.current.x;
71
+ const dy = e.clientY - startPointer.current.y;
72
+ if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
73
+ didDragRef.current = true;
74
+ }
75
+
76
+ el.style.transition = 'none';
77
+ el.style.transform = `translate(${dx}px, ${dy}px)`;
78
+
79
+ const now = performance.now();
80
+ samples.current.push({ x: e.clientX, t: now });
81
+ if (samples.current.length > 5) samples.current.shift();
82
+ };
83
+
84
+ const handleUp = (e: PointerEvent) => {
85
+ if (!draggingRef.current) return;
86
+ draggingRef.current = false;
87
+
88
+ const el = containerRef.current;
89
+ if (!el) return;
90
+
91
+ // ── Velocity ────────────────────────────────────
92
+ let velocityX = 0;
93
+ const s = samples.current;
94
+ if (s.length >= 2) {
95
+ const first = s[0];
96
+ const last = s[s.length - 1];
97
+ const dt = last.t - first.t;
98
+ if (dt > 0) velocityX = (last.x - first.x) / dt;
99
+ }
100
+ samples.current = [];
101
+
102
+ // ── Target side ─────────────────────────────────
103
+ const currentVisualX = startRect.current.x + (e.clientX - startPointer.current.x);
104
+ const screenMid = window.innerWidth / 2;
105
+ let targetSide: 'left' | 'right';
106
+
107
+ if (Math.abs(velocityX) > VELOCITY_THRESHOLD) {
108
+ targetSide = velocityX < 0 ? 'left' : 'right';
109
+ } else {
110
+ targetSide = (currentVisualX + FAB_SIZE / 2) < screenMid ? 'left' : 'right';
111
+ }
112
+
113
+ // ── Animate from current position to target resting position ──
114
+ // Both expressed as offsets from the CURRENT CSS base (old position).
115
+ // We do NOT update React state until animation finishes.
116
+ const oldBaseX = restingX(positionRef.current);
117
+ const oldBaseY = restingY();
118
+
119
+ const currentX = startRect.current.x + (e.clientX - startPointer.current.x);
120
+ const currentY = startRect.current.y + (e.clientY - startPointer.current.y);
121
+
122
+ const targetX = restingX(targetSide);
123
+ const targetY = restingY();
124
+
125
+ // Current visual position as translate from old CSS base
126
+ const fromTx = currentX - oldBaseX;
127
+ const fromTy = currentY - oldBaseY;
128
+
129
+ // Target resting position as translate from old CSS base
130
+ const toTx = targetX - oldBaseX;
131
+ const toTy = targetY - oldBaseY;
132
+
133
+ // Ensure we're at the "from" position with no transition
134
+ el.style.transition = 'none';
135
+ el.style.transform = `translate(${fromTx}px, ${fromTy}px)`;
136
+
137
+ // Force reflow
138
+ void el.offsetHeight;
139
+
140
+ // Animate to target
141
+ el.style.transition = `transform ${SETTLE_DURATION}ms ${SETTLE_EASING}`;
142
+ el.style.transform = `translate(${toTx}px, ${toTy}px)`;
143
+
144
+ // After animation: clean up DOM styles, update React state
145
+ const finish = () => {
146
+ el.style.transition = '';
147
+ el.style.transform = '';
148
+ el.removeEventListener('transitionend', finish);
149
+ // Now update React state — this re-renders with correct CSS class
150
+ setIsDragging(false);
151
+ if (targetSide !== positionRef.current) {
152
+ setPosition(targetSide);
153
+ storePosition(targetSide);
154
+ }
155
+ };
156
+
157
+ el.addEventListener('transitionend', finish, { once: true });
158
+ // Safety fallback
159
+ setTimeout(finish, SETTLE_DURATION + 50);
160
+ };
161
+
162
+ document.addEventListener('pointermove', handleMove);
163
+ document.addEventListener('pointerup', handleUp);
164
+ return () => {
165
+ document.removeEventListener('pointermove', handleMove);
166
+ document.removeEventListener('pointerup', handleUp);
167
+ };
55
168
  }, []);
56
169
 
57
- const handlePointerMove = useCallback((_e: React.PointerEvent) => {
58
- // Visual feedback could be added here (e.g., opacity change)
170
+ const handlePointerDown = useCallback((e: React.PointerEvent) => {
171
+ e.preventDefault();
172
+ const el = containerRef.current;
173
+ if (!el) return;
174
+
175
+ const rect = el.getBoundingClientRect();
176
+ startPointer.current = { x: e.clientX, y: e.clientY };
177
+ startRect.current = { x: rect.left, y: rect.top };
178
+ draggingRef.current = true;
179
+ didDragRef.current = false;
180
+ samples.current = [];
181
+ setIsDragging(true);
59
182
  }, []);
60
183
 
61
- const handlePointerUp = useCallback(
62
- (e: React.PointerEvent) => {
63
- setIsDragging(false);
64
- (e.target as HTMLElement).releasePointerCapture(e.pointerId);
65
-
66
- const deltaX = e.clientX - dragStartX.current;
67
- // Only snap if dragged more than 50px horizontally
68
- if (Math.abs(deltaX) > 50) {
69
- const screenMid = window.innerWidth / 2;
70
- const newPosition = e.clientX < screenMid ? 'left' : 'right';
71
- if (newPosition !== position) {
72
- setPosition(newPosition);
73
- storePosition(newPosition);
74
- }
75
- }
76
- },
77
- [position]
78
- );
184
+ const shouldSuppressClick = useCallback(() => didDragRef.current, []);
79
185
 
80
186
  return {
81
187
  position,
82
188
  isDragging,
83
189
  showHint,
84
- fabRef,
190
+ containerRef,
191
+ shouldSuppressClick,
85
192
  handlers: {
86
193
  onPointerDown: handlePointerDown,
87
- onPointerMove: handlePointerMove,
88
- onPointerUp: handlePointerUp,
89
194
  },
90
195
  };
91
196
  }