@xcelsior/ui-chat 1.0.7 → 2.0.0
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/CHANGELOG.md +10 -0
- package/dist/index.d.mts +69 -69
- package/dist/index.d.ts +69 -69
- package/dist/index.js +2458 -627
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2457 -628
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -5
- package/src/components/BrandIcons.stories.tsx +95 -0
- package/src/components/BrandIcons.tsx +84 -0
- package/src/components/Chat.stories.tsx +149 -16
- package/src/components/Chat.tsx +116 -96
- package/src/components/ChatHeader.tsx +124 -69
- package/src/components/ChatInput.tsx +253 -104
- package/src/components/ChatWidget.tsx +209 -63
- package/src/components/ConversationRating.stories.tsx +33 -0
- package/src/components/ConversationRating.tsx +156 -0
- package/src/components/MarkdownMessage.tsx +202 -0
- package/src/components/MessageItem.stories.tsx +253 -55
- package/src/components/MessageItem.tsx +222 -59
- package/src/components/MessageList.tsx +164 -35
- package/src/components/PreChatForm.tsx +236 -96
- package/src/components/ThinkingIndicator.tsx +370 -0
- package/src/components/TypingIndicator.tsx +27 -11
- package/src/hooks/useDraggablePosition.ts +91 -0
- package/src/hooks/useMessages.ts +12 -13
- package/src/hooks/useResizableWidget.ts +324 -0
- package/src/index.tsx +5 -0
- package/src/types.ts +51 -5
- package/src/utils/markdown-styles.ts +140 -0
- package/storybook-static/assets/BrandIcons-Cjy5INAp.js +4 -0
- package/storybook-static/assets/BrandIcons.stories-BeVC6svr.js +64 -0
- package/storybook-static/assets/Chat.stories-J_Yp51wU.js +803 -0
- package/storybook-static/assets/Color-YHDXOIA2-BMnd3YrF.js +1 -0
- package/storybook-static/assets/ConversationRating.stories-B5_QddHN.js +12 -0
- package/storybook-static/assets/DocsRenderer-CFRXHY34-i_W8iCu9.js +575 -0
- package/storybook-static/assets/MessageItem-DAaKZ9s9.js +14 -0
- package/storybook-static/assets/MessageItem.stories-Ckr1_scc.js +255 -0
- package/storybook-static/assets/ToastContext-Bty1K7ya.js +1 -0
- package/storybook-static/assets/chunk-XP5HYGXS-BpfKkqn7.js +1 -0
- package/storybook-static/assets/en-US-BukEqXxE.js +1 -0
- package/storybook-static/assets/entry-preview-docs-DHohToDm.js +46 -0
- package/storybook-static/assets/entry-preview-oDnntGcx.js +2 -0
- package/storybook-static/assets/iframe-CGBtu2Se.js +211 -0
- package/storybook-static/assets/index--qcDGAq6.js +1 -0
- package/storybook-static/assets/index-BLHw34Di.js +24 -0
- package/storybook-static/assets/index-B_4m48Mv.js +1 -0
- package/storybook-static/assets/index-DgH-xKnr.js +11 -0
- package/storybook-static/assets/index-DrFu-skq.js +6 -0
- package/storybook-static/assets/index-DrdPSA1J.js +240 -0
- package/storybook-static/assets/index-jvNEZhzf.js +1 -0
- package/storybook-static/assets/index-yBjzXJbu.js +9 -0
- package/storybook-static/assets/jsx-runtime-Cf8x2fCZ.js +9 -0
- package/storybook-static/assets/preview-B8lJiyuQ.js +34 -0
- package/storybook-static/assets/preview-BBWR9nbA.js +1 -0
- package/storybook-static/assets/preview-BRpahs9B.js +2 -0
- package/storybook-static/assets/preview-BWzBA1C2.js +396 -0
- package/storybook-static/assets/preview-CvbIS5ZJ.js +1 -0
- package/storybook-static/assets/preview-DD_OYowb.js +1 -0
- package/storybook-static/assets/preview-DGUiP6tS.js +7 -0
- package/storybook-static/assets/preview-DHQbi4pV.js +1 -0
- package/storybook-static/assets/preview-DUOvJmsz.js +1 -0
- package/storybook-static/assets/preview-DcGwT3kv.css +1 -0
- package/storybook-static/assets/preview-DwI0w3cI.js +1 -0
- package/storybook-static/assets/react-18-CALspjOX.js +1 -0
- package/storybook-static/assets/test-utils-BE0XkMtV.js +9 -0
- package/storybook-static/favicon.svg +1 -0
- package/storybook-static/iframe.html +666 -0
- package/storybook-static/index.html +177 -0
- package/storybook-static/index.json +1 -0
- package/storybook-static/nunito-sans-bold-italic.woff2 +0 -0
- package/storybook-static/nunito-sans-bold.woff2 +0 -0
- package/storybook-static/nunito-sans-italic.woff2 +0 -0
- package/storybook-static/nunito-sans-regular.woff2 +0 -0
- package/storybook-static/project.json +1 -0
- package/storybook-static/sb-addons/essentials-actions-3/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-backgrounds-5/manager-bundle.js +12 -0
- package/storybook-static/sb-addons/essentials-controls-2/manager-bundle.js +405 -0
- package/storybook-static/sb-addons/essentials-docs-4/manager-bundle.js +245 -0
- package/storybook-static/sb-addons/essentials-measure-8/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-outline-9/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-toolbars-7/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-viewport-6/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/interactions-10/manager-bundle.js +222 -0
- package/storybook-static/sb-addons/links-1/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js +3 -0
- package/storybook-static/sb-common-assets/favicon.svg +1 -0
- package/storybook-static/sb-common-assets/nunito-sans-bold-italic.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-bold.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-italic.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-regular.woff2 +0 -0
- package/storybook-static/sb-manager/globals-module-info.js +1052 -0
- package/storybook-static/sb-manager/globals-runtime.js +42127 -0
- package/storybook-static/sb-manager/globals.js +48 -0
- package/storybook-static/sb-manager/runtime.js +12048 -0
- package/.turbo/turbo-lint.log +0 -5
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { XcelsiorAvatar } from './BrandIcons';
|
|
3
|
+
import type { IChatTheme } from '../types';
|
|
4
|
+
|
|
5
|
+
// ─── Phrase Pools ────────────────────────────────────────────────────────────
|
|
6
|
+
// Each pool is tied to a detected conversation context.
|
|
7
|
+
// Phrases should feel casual, human, and short (≤4 words ideal).
|
|
8
|
+
|
|
9
|
+
const PHRASE_POOLS: Record<string, string[]> = {
|
|
10
|
+
// ── Greetings & small talk ──
|
|
11
|
+
greeting: [
|
|
12
|
+
'Hey there!',
|
|
13
|
+
'Hello!',
|
|
14
|
+
'Hi! One moment',
|
|
15
|
+
'Welcome!',
|
|
16
|
+
'Nice to meet you',
|
|
17
|
+
],
|
|
18
|
+
farewell: [
|
|
19
|
+
'Wrapping up',
|
|
20
|
+
'One last thing',
|
|
21
|
+
'Almost done',
|
|
22
|
+
],
|
|
23
|
+
|
|
24
|
+
// ── Sales & pricing ──
|
|
25
|
+
pricing: [
|
|
26
|
+
'Crunching numbers',
|
|
27
|
+
'Checking our plans',
|
|
28
|
+
'Let me look into that',
|
|
29
|
+
'Pulling up pricing',
|
|
30
|
+
'Running the numbers',
|
|
31
|
+
'Checking options',
|
|
32
|
+
],
|
|
33
|
+
quote: [
|
|
34
|
+
'Putting this together',
|
|
35
|
+
'Working on your quote',
|
|
36
|
+
'Gathering the details',
|
|
37
|
+
'Tailoring this for you',
|
|
38
|
+
],
|
|
39
|
+
|
|
40
|
+
// ── Scheduling & booking ──
|
|
41
|
+
booking: [
|
|
42
|
+
'Checking the calendar',
|
|
43
|
+
'Finding good times',
|
|
44
|
+
'Pulling up availability',
|
|
45
|
+
'Let me check slots',
|
|
46
|
+
],
|
|
47
|
+
|
|
48
|
+
// ── Technical questions ──
|
|
49
|
+
technical: [
|
|
50
|
+
'Diving into the docs',
|
|
51
|
+
'Interesting question',
|
|
52
|
+
'Let me look that up',
|
|
53
|
+
'Checking the specs',
|
|
54
|
+
'Hmm, good one',
|
|
55
|
+
'Researching this',
|
|
56
|
+
],
|
|
57
|
+
code: [
|
|
58
|
+
'Reading the code',
|
|
59
|
+
'Checking the repo',
|
|
60
|
+
'Let me trace that',
|
|
61
|
+
'Debugging in my head',
|
|
62
|
+
],
|
|
63
|
+
|
|
64
|
+
// ── Support & issues ──
|
|
65
|
+
support: [
|
|
66
|
+
'On it!',
|
|
67
|
+
'Let me help',
|
|
68
|
+
'Looking into this',
|
|
69
|
+
'Checking for you',
|
|
70
|
+
'I got you',
|
|
71
|
+
'Investigating',
|
|
72
|
+
],
|
|
73
|
+
frustrated: [
|
|
74
|
+
'I hear you',
|
|
75
|
+
'Let me fix this',
|
|
76
|
+
'Sorry about that',
|
|
77
|
+
'Working on it now',
|
|
78
|
+
'Bear with me',
|
|
79
|
+
],
|
|
80
|
+
|
|
81
|
+
// ── Services & capabilities ──
|
|
82
|
+
services: [
|
|
83
|
+
'Great question',
|
|
84
|
+
'Let me explain',
|
|
85
|
+
'Pulling up details',
|
|
86
|
+
'Good to know you ask',
|
|
87
|
+
],
|
|
88
|
+
portfolio: [
|
|
89
|
+
'Checking our work',
|
|
90
|
+
'Pulling up examples',
|
|
91
|
+
'Let me show you',
|
|
92
|
+
'Finding case studies',
|
|
93
|
+
],
|
|
94
|
+
|
|
95
|
+
// ── About the company ──
|
|
96
|
+
about: [
|
|
97
|
+
'Glad you asked!',
|
|
98
|
+
'Let me tell you',
|
|
99
|
+
'Good question',
|
|
100
|
+
'Here we go',
|
|
101
|
+
],
|
|
102
|
+
|
|
103
|
+
// ── Comparison & decisions ──
|
|
104
|
+
comparison: [
|
|
105
|
+
'Weighing the options',
|
|
106
|
+
'Let me compare',
|
|
107
|
+
'Thinking through this',
|
|
108
|
+
'Good point',
|
|
109
|
+
],
|
|
110
|
+
|
|
111
|
+
// ── Follow-up & continuation ──
|
|
112
|
+
followUp: [
|
|
113
|
+
'Let me dig deeper',
|
|
114
|
+
'More details coming',
|
|
115
|
+
'Building on that',
|
|
116
|
+
'Expanding on this',
|
|
117
|
+
],
|
|
118
|
+
|
|
119
|
+
// ── General / fallback ──
|
|
120
|
+
general: [
|
|
121
|
+
'Hmm, let me think',
|
|
122
|
+
'One sec',
|
|
123
|
+
'On it',
|
|
124
|
+
'Working on it',
|
|
125
|
+
'Almost there',
|
|
126
|
+
'Bear with me',
|
|
127
|
+
'Let me check',
|
|
128
|
+
'Good question',
|
|
129
|
+
'Hang tight',
|
|
130
|
+
'Looking into it',
|
|
131
|
+
'Let me see',
|
|
132
|
+
'Thinking',
|
|
133
|
+
'Just a moment',
|
|
134
|
+
'Figuring this out',
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// ─── Context Detection ───────────────────────────────────────────────────────
|
|
139
|
+
// Ordered by specificity — first match wins.
|
|
140
|
+
|
|
141
|
+
interface ContextRule {
|
|
142
|
+
key: string;
|
|
143
|
+
pattern: RegExp;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const CONTEXT_RULES: ContextRule[] = [
|
|
147
|
+
// Frustration / urgency (check first — trumps topic)
|
|
148
|
+
{ key: 'frustrated', pattern: /frustrat|angry|annoyed|terrible|worst|useless|waste|stupid|wtf|seriously|ridiculous|unacceptable|disappointing/ },
|
|
149
|
+
|
|
150
|
+
// Greetings (start of message)
|
|
151
|
+
{ key: 'greeting', pattern: /^(hi\b|hey\b|hello\b|good morning|good afternoon|good evening|g'day|howdy|yo\b|sup\b|what's up|greetings)/ },
|
|
152
|
+
{ key: 'farewell', pattern: /\b(thanks|thank you|bye|goodbye|cheers|that's all|that's it|no more|all good|perfect thanks)\b/ },
|
|
153
|
+
|
|
154
|
+
// Booking & scheduling
|
|
155
|
+
{ key: 'booking', pattern: /\b(book|schedule|meeting|appointment|calendar|available time|free slot|set up a call|arrange|consultation time|when can)\b/ },
|
|
156
|
+
|
|
157
|
+
// Pricing & quotes
|
|
158
|
+
{ key: 'quote', pattern: /\b(quote|proposal|estimate|project cost|custom price|tailor|bespoke)\b/ },
|
|
159
|
+
{ key: 'pricing', pattern: /\b(price|cost|how much|pricing|rate|fee|budget|afford|charge|per hour|hourly|package|plan|tier|subscription)\b/ },
|
|
160
|
+
|
|
161
|
+
// Technical / code
|
|
162
|
+
{ key: 'code', pattern: /\b(code|github|repo|commit|deploy|ci\/cd|docker|aws|lambda|api endpoint|sdk|npm|yarn|pnpm|typescript|react|next\.?js)\b/ },
|
|
163
|
+
{ key: 'technical', pattern: /\b(api|integrate|integration|stack|server|database|backend|frontend|infrastructure|architecture|performance|scalab|migration|devops|cloud)\b/ },
|
|
164
|
+
|
|
165
|
+
// Support & bugs
|
|
166
|
+
{ key: 'support', pattern: /\b(bug|error|issue|broken|not working|help|support|problem|fix|crash|down|fail|stuck|trouble|can't|cannot|doesn't work|won't)\b/ },
|
|
167
|
+
|
|
168
|
+
// Services & portfolio
|
|
169
|
+
{ key: 'portfolio', pattern: /\b(portfolio|case stud|example|previous work|client|project you|showcase|demo|sample)\b/ },
|
|
170
|
+
{ key: 'services', pattern: /\b(service|what do you|what can you|offer|do you do|capabilit|specializ|expertise|solution|consult|develop|design|build|create)\b/ },
|
|
171
|
+
|
|
172
|
+
// About company
|
|
173
|
+
{ key: 'about', pattern: /\b(who are|about you|your team|your company|where are you|location|office|founded|history|values|mission)\b/ },
|
|
174
|
+
|
|
175
|
+
// Comparison / decision-making
|
|
176
|
+
{ key: 'comparison', pattern: /\b(compare|vs|versus|difference|better|which one|alternative|competitor|pros and cons|trade.?off|should i|recommend)\b/ },
|
|
177
|
+
|
|
178
|
+
// Follow-up patterns (vague follow-ups to prior answers)
|
|
179
|
+
{ key: 'followUp', pattern: /\b(more detail|tell me more|elaborate|explain further|what about|and also|another question|one more|can you also|what else|go on)\b/ },
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
function detectContext(message?: string): string {
|
|
183
|
+
if (!message) return 'general';
|
|
184
|
+
const lower = message.toLowerCase().trim();
|
|
185
|
+
|
|
186
|
+
for (const rule of CONTEXT_RULES) {
|
|
187
|
+
if (rule.pattern.test(lower)) return rule.key;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Short messages (1-3 words) that didn't match anything → likely casual
|
|
191
|
+
if (lower.split(/\s+/).length <= 3) return 'general';
|
|
192
|
+
|
|
193
|
+
// Questions tend to be exploratory
|
|
194
|
+
if (lower.endsWith('?')) return 'general';
|
|
195
|
+
|
|
196
|
+
return 'general';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Shuffle Utility ─────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
function getShuffledPhrases(context: string): string[] {
|
|
202
|
+
const pool = PHRASE_POOLS[context] || PHRASE_POOLS.general;
|
|
203
|
+
const contextPhrases = [...pool];
|
|
204
|
+
|
|
205
|
+
// Mix in 2-3 general phrases for variety (avoid duplicates)
|
|
206
|
+
const extras = PHRASE_POOLS.general
|
|
207
|
+
.filter((p) => !contextPhrases.includes(p))
|
|
208
|
+
.sort(() => Math.random() - 0.5)
|
|
209
|
+
.slice(0, 3);
|
|
210
|
+
|
|
211
|
+
const combined = [...contextPhrases, ...extras];
|
|
212
|
+
|
|
213
|
+
// Fisher-Yates shuffle
|
|
214
|
+
for (let i = combined.length - 1; i > 0; i--) {
|
|
215
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
216
|
+
[combined[i], combined[j]] = [combined[j], combined[i]];
|
|
217
|
+
}
|
|
218
|
+
return combined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Theme Helpers ───────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
function computeIsLightTheme(bg?: string): boolean {
|
|
224
|
+
if (!bg?.startsWith('#')) return false;
|
|
225
|
+
const hex = bg.replace('#', '');
|
|
226
|
+
const r = parseInt(hex.substring(0, 2), 16);
|
|
227
|
+
const g = parseInt(hex.substring(2, 4), 16);
|
|
228
|
+
const b = parseInt(hex.substring(4, 6), 16);
|
|
229
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.5;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Component ───────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
export interface ThinkingIndicatorProps {
|
|
235
|
+
/** Last message the user sent — used to pick contextual phrases */
|
|
236
|
+
lastUserMessage?: string;
|
|
237
|
+
/** Chat theme for color matching */
|
|
238
|
+
theme?: IChatTheme;
|
|
239
|
+
/** Show avatar next to the indicator */
|
|
240
|
+
showAvatar?: boolean;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function ThinkingIndicator({
|
|
244
|
+
lastUserMessage,
|
|
245
|
+
theme,
|
|
246
|
+
showAvatar = true,
|
|
247
|
+
}: ThinkingIndicatorProps) {
|
|
248
|
+
const isLightTheme = computeIsLightTheme(theme?.background);
|
|
249
|
+
const primaryColor = theme?.primary || '#337eff';
|
|
250
|
+
const textMuted = theme?.textMuted || (isLightTheme ? 'rgba(0,0,0,0.4)' : 'rgba(247,247,248,0.45)');
|
|
251
|
+
|
|
252
|
+
const [phraseIndex, setPhraseIndex] = useState(0);
|
|
253
|
+
const [displayText, setDisplayText] = useState('');
|
|
254
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
255
|
+
const phrasesRef = useRef<string[]>(getShuffledPhrases(detectContext(lastUserMessage)));
|
|
256
|
+
|
|
257
|
+
// Re-shuffle when user sends a new message
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
phrasesRef.current = getShuffledPhrases(detectContext(lastUserMessage));
|
|
260
|
+
setPhraseIndex(0);
|
|
261
|
+
setDisplayText('');
|
|
262
|
+
setIsDeleting(false);
|
|
263
|
+
}, [lastUserMessage]);
|
|
264
|
+
|
|
265
|
+
// Typewriter effect
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
const phrases = phrasesRef.current;
|
|
268
|
+
const phrase = phrases[phraseIndex % phrases.length];
|
|
269
|
+
let timeout: NodeJS.Timeout;
|
|
270
|
+
|
|
271
|
+
if (!isDeleting) {
|
|
272
|
+
if (displayText.length < phrase.length) {
|
|
273
|
+
// Typing forward — variable speed for natural feel
|
|
274
|
+
timeout = setTimeout(
|
|
275
|
+
() => setDisplayText(phrase.slice(0, displayText.length + 1)),
|
|
276
|
+
30 + Math.random() * 30,
|
|
277
|
+
);
|
|
278
|
+
} else {
|
|
279
|
+
// Pause at full phrase before deleting
|
|
280
|
+
timeout = setTimeout(() => setIsDeleting(true), 1800);
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
if (displayText.length > 0) {
|
|
284
|
+
// Deleting — faster than typing
|
|
285
|
+
timeout = setTimeout(
|
|
286
|
+
() => setDisplayText(displayText.slice(0, -1)),
|
|
287
|
+
18,
|
|
288
|
+
);
|
|
289
|
+
} else {
|
|
290
|
+
// Advance to next phrase
|
|
291
|
+
setIsDeleting(false);
|
|
292
|
+
setPhraseIndex((prev) => (prev + 1) % phrasesRef.current.length);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return () => clearTimeout(timeout);
|
|
297
|
+
}, [displayText, isDeleting, phraseIndex]);
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<div
|
|
301
|
+
className="flex gap-2.5 mb-3"
|
|
302
|
+
role="status"
|
|
303
|
+
aria-live="polite"
|
|
304
|
+
aria-label="Xcelsior is thinking"
|
|
305
|
+
>
|
|
306
|
+
{showAvatar && (
|
|
307
|
+
<div className="flex-shrink-0 mt-auto mb-5">
|
|
308
|
+
<XcelsiorAvatar size={28} />
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
<div className="flex flex-col items-start">
|
|
312
|
+
<div
|
|
313
|
+
style={{
|
|
314
|
+
backgroundColor: isLightTheme
|
|
315
|
+
? 'rgba(0,0,0,0.04)'
|
|
316
|
+
: 'rgba(255,255,255,0.04)',
|
|
317
|
+
borderRadius: '18px 18px 18px 4px',
|
|
318
|
+
boxShadow: isLightTheme
|
|
319
|
+
? 'inset 0 0 0 1px rgba(0,0,0,0.06)'
|
|
320
|
+
: 'inset 0 0 0 0.5px rgba(255,255,255,0.06), inset 0 1px 0 0 rgba(255,255,255,0.08)',
|
|
321
|
+
padding: '12px 16px',
|
|
322
|
+
minWidth: 160,
|
|
323
|
+
}}
|
|
324
|
+
>
|
|
325
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
326
|
+
{/* Pulsing dots */}
|
|
327
|
+
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
328
|
+
{[0, 1, 2].map((i) => (
|
|
329
|
+
<span
|
|
330
|
+
key={i}
|
|
331
|
+
style={{
|
|
332
|
+
width: 5,
|
|
333
|
+
height: 5,
|
|
334
|
+
borderRadius: '50%',
|
|
335
|
+
backgroundColor: primaryColor,
|
|
336
|
+
display: 'inline-block',
|
|
337
|
+
animation: `thinkingPulse 1.4s ease-in-out ${i * 0.2}s infinite`,
|
|
338
|
+
}}
|
|
339
|
+
/>
|
|
340
|
+
))}
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
{/* Typewriter text + blinking cursor */}
|
|
344
|
+
<span
|
|
345
|
+
style={{
|
|
346
|
+
fontSize: 12,
|
|
347
|
+
color: textMuted,
|
|
348
|
+
letterSpacing: '0.015em',
|
|
349
|
+
fontStyle: 'italic',
|
|
350
|
+
}}
|
|
351
|
+
>
|
|
352
|
+
{displayText}
|
|
353
|
+
<span
|
|
354
|
+
style={{
|
|
355
|
+
display: 'inline-block',
|
|
356
|
+
width: 1,
|
|
357
|
+
height: 13,
|
|
358
|
+
backgroundColor: textMuted,
|
|
359
|
+
marginLeft: 1,
|
|
360
|
+
animation: 'cursorBlink 0.8s step-end infinite',
|
|
361
|
+
verticalAlign: 'text-bottom',
|
|
362
|
+
}}
|
|
363
|
+
/>
|
|
364
|
+
</span>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
@@ -9,20 +9,36 @@ export function TypingIndicator({ isTyping, userName }: TypingIndicatorProps) {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
return (
|
|
12
|
-
<div
|
|
12
|
+
<div
|
|
13
|
+
className="px-4 py-2"
|
|
14
|
+
style={{
|
|
15
|
+
borderTop: '1px solid rgba(255,255,255,0.06)',
|
|
16
|
+
backgroundColor: 'rgba(0,0,0,0.15)',
|
|
17
|
+
}}
|
|
18
|
+
>
|
|
13
19
|
<div className="flex items-center gap-2">
|
|
14
20
|
<div className="flex gap-1">
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
{[0, 1, 2].map((i) => (
|
|
22
|
+
<span
|
|
23
|
+
key={i}
|
|
24
|
+
className="rounded-full animate-bounce"
|
|
25
|
+
style={{
|
|
26
|
+
width: 5,
|
|
27
|
+
height: 5,
|
|
28
|
+
backgroundColor: 'rgba(51,126,255,0.6)',
|
|
29
|
+
animationDelay: `${i * 0.1}s`,
|
|
30
|
+
animationDuration: '0.8s',
|
|
31
|
+
}}
|
|
32
|
+
/>
|
|
33
|
+
))}
|
|
24
34
|
</div>
|
|
25
|
-
<span
|
|
35
|
+
<span
|
|
36
|
+
style={{
|
|
37
|
+
fontSize: '12px',
|
|
38
|
+
letterSpacing: '0.015em',
|
|
39
|
+
color: 'rgba(247,247,248,0.45)',
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
26
42
|
{userName ? `${userName} is typing...` : 'Someone is typing...'}
|
|
27
43
|
</span>
|
|
28
44
|
</div>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
import type { WidgetPosition } from '../types';
|
|
3
|
+
|
|
4
|
+
const STORAGE_KEY = 'xcelsior-chat-position';
|
|
5
|
+
|
|
6
|
+
function getStoredPosition(): 'left' | 'right' {
|
|
7
|
+
try {
|
|
8
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
9
|
+
if (stored === 'left' || stored === 'right') return stored;
|
|
10
|
+
} catch {
|
|
11
|
+
// localStorage not available
|
|
12
|
+
}
|
|
13
|
+
return 'right';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function storePosition(position: 'left' | 'right') {
|
|
17
|
+
try {
|
|
18
|
+
localStorage.setItem(STORAGE_KEY, position);
|
|
19
|
+
} catch {
|
|
20
|
+
// localStorage not available
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useDraggablePosition(configPosition: WidgetPosition = 'auto') {
|
|
25
|
+
const resolvedDefault = configPosition === 'auto' ? getStoredPosition() : configPosition;
|
|
26
|
+
const [position, setPosition] = useState<'left' | 'right'>(resolvedDefault);
|
|
27
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
28
|
+
const dragStartX = useRef(0);
|
|
29
|
+
const fabRef = useRef<HTMLButtonElement>(null);
|
|
30
|
+
const hasShownHint = useRef(false);
|
|
31
|
+
const [showHint, setShowHint] = useState(false);
|
|
32
|
+
|
|
33
|
+
// Show bounce hint on first visit
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
try {
|
|
36
|
+
const hasVisited = localStorage.getItem('xcelsior-chat-hint-shown');
|
|
37
|
+
if (!hasVisited && !hasShownHint.current) {
|
|
38
|
+
hasShownHint.current = true;
|
|
39
|
+
setShowHint(true);
|
|
40
|
+
const timer = setTimeout(() => {
|
|
41
|
+
setShowHint(false);
|
|
42
|
+
localStorage.setItem('xcelsior-chat-hint-shown', 'true');
|
|
43
|
+
}, 2000);
|
|
44
|
+
return () => clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// localStorage not available
|
|
48
|
+
}
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
52
|
+
dragStartX.current = e.clientX;
|
|
53
|
+
setIsDragging(true);
|
|
54
|
+
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const handlePointerMove = useCallback((_e: React.PointerEvent) => {
|
|
58
|
+
// Visual feedback could be added here (e.g., opacity change)
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
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
|
+
);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
position,
|
|
82
|
+
isDragging,
|
|
83
|
+
showHint,
|
|
84
|
+
fabRef,
|
|
85
|
+
handlers: {
|
|
86
|
+
onPointerDown: handlePointerDown,
|
|
87
|
+
onPointerMove: handlePointerMove,
|
|
88
|
+
onPointerUp: handlePointerUp,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/hooks/useMessages.ts
CHANGED
|
@@ -1,20 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
-
import type { IMessage, IChatConfig } from '../types';
|
|
2
|
+
import type { IMessage, IChatConfig, UseMessagesReturn } from '../types';
|
|
3
3
|
import type { UseWebSocketReturn } from './useWebSocket';
|
|
4
4
|
import { fetchMessages } from '../utils/api';
|
|
5
5
|
|
|
6
|
-
export interface UseMessagesReturn {
|
|
7
|
-
messages: IMessage[];
|
|
8
|
-
addMessage: (message: IMessage) => void;
|
|
9
|
-
updateMessageStatus: (messageId: string, status: IMessage['status']) => void;
|
|
10
|
-
clearMessages: () => void;
|
|
11
|
-
isLoading: boolean;
|
|
12
|
-
error: Error | null;
|
|
13
|
-
loadMore: () => Promise<void>;
|
|
14
|
-
hasMore: boolean;
|
|
15
|
-
isLoadingMore: boolean;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
6
|
export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig): UseMessagesReturn {
|
|
19
7
|
const [messages, setMessages] = useState<IMessage[]>([]);
|
|
20
8
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -22,6 +10,7 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
|
|
|
22
10
|
const [nextPageToken, setNextPageToken] = useState<string | undefined>(undefined);
|
|
23
11
|
const [hasMore, setHasMore] = useState(true);
|
|
24
12
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
13
|
+
const [isBotThinking, setIsBotThinking] = useState(false);
|
|
25
14
|
|
|
26
15
|
// Extract stable references from config
|
|
27
16
|
const { httpApiUrl, conversationId, headers, onError, toast } = config;
|
|
@@ -89,6 +78,11 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
|
|
|
89
78
|
return [...prev, newMessage];
|
|
90
79
|
});
|
|
91
80
|
|
|
81
|
+
// Clear bot thinking state when bot or system message arrives
|
|
82
|
+
if (newMessage.senderType === 'bot' || newMessage.senderType === 'system') {
|
|
83
|
+
setIsBotThinking(false);
|
|
84
|
+
}
|
|
85
|
+
|
|
92
86
|
// Notify parent component about new message
|
|
93
87
|
onMessageReceived?.(newMessage);
|
|
94
88
|
}
|
|
@@ -102,6 +96,10 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
|
|
|
102
96
|
}
|
|
103
97
|
return [...prev, message];
|
|
104
98
|
});
|
|
99
|
+
// Show bot thinking indicator immediately when customer sends a message
|
|
100
|
+
if (message.senderType === 'customer') {
|
|
101
|
+
setIsBotThinking(true);
|
|
102
|
+
}
|
|
105
103
|
}, []);
|
|
106
104
|
|
|
107
105
|
const updateMessageStatus = useCallback((messageId: string, status: IMessage['status']) => {
|
|
@@ -161,5 +159,6 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
|
|
|
161
159
|
loadMore,
|
|
162
160
|
hasMore,
|
|
163
161
|
isLoadingMore,
|
|
162
|
+
isBotThinking,
|
|
164
163
|
};
|
|
165
164
|
}
|