@xcelsior/ui-chat 1.0.1
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/main.ts +27 -0
- package/.storybook/preview.tsx +39 -0
- package/CHANGELOG.md +32 -0
- package/README.md +526 -0
- package/biome.json +3 -0
- package/package.json +61 -0
- package/postcss.config.js +5 -0
- package/src/components/Chat.stories.tsx +54 -0
- package/src/components/Chat.tsx +194 -0
- package/src/components/ChatHeader.tsx +93 -0
- package/src/components/ChatInput.tsx +363 -0
- package/src/components/ChatWidget.tsx +234 -0
- package/src/components/MessageItem.stories.tsx +232 -0
- package/src/components/MessageItem.tsx +143 -0
- package/src/components/MessageList.tsx +189 -0
- package/src/components/PreChatForm.tsx +202 -0
- package/src/components/TypingIndicator.tsx +31 -0
- package/src/hooks/useFileUpload.ts +134 -0
- package/src/hooks/useMessages.ts +165 -0
- package/src/hooks/useTypingIndicator.ts +33 -0
- package/src/hooks/useWebSocket.ts +209 -0
- package/src/index.tsx +46 -0
- package/src/types.ts +145 -0
- package/src/utils/api.ts +43 -0
- package/tsconfig.json +5 -0
- package/tsup.config.ts +12 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Chat } from './Chat';
|
|
3
|
+
import type { IChatConfig } from '../types';
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Chat> = {
|
|
6
|
+
title: 'Components/Chat',
|
|
7
|
+
component: Chat,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'fullscreen',
|
|
10
|
+
},
|
|
11
|
+
tags: ['autodocs'],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof Chat>;
|
|
16
|
+
|
|
17
|
+
// Base configuration without user details
|
|
18
|
+
const baseConfig: Omit<IChatConfig, 'currentUser' | 'conversationId' | 'userId'> = {
|
|
19
|
+
websocketUrl: 'wss://dev-ws-chat.xcelsior.co',
|
|
20
|
+
httpApiUrl: 'https://dev-chat.xcelsior.co/api',
|
|
21
|
+
enableEmoji: true,
|
|
22
|
+
enableFileUpload: true,
|
|
23
|
+
enableTypingIndicator: true,
|
|
24
|
+
fileUpload: {
|
|
25
|
+
uploadUrl: `https://dev-chat.xcelsior.co/api/attachments/upload-url`,
|
|
26
|
+
maxFileSize: 2 * 1024 * 1024, // 2MB (default)
|
|
27
|
+
allowedTypes: [
|
|
28
|
+
'image/jpeg',
|
|
29
|
+
'image/png',
|
|
30
|
+
'image/gif',
|
|
31
|
+
'image/webp',
|
|
32
|
+
'application/pdf',
|
|
33
|
+
'application/msword',
|
|
34
|
+
'text/plain',
|
|
35
|
+
'text/csv',
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
enableReadReceipts: true,
|
|
39
|
+
apiKey: 'test',
|
|
40
|
+
toast: {
|
|
41
|
+
success: (message: string) => console.log('Success:', message),
|
|
42
|
+
error: (message: string) => console.error('Error:', message),
|
|
43
|
+
info: (message: string) => console.log('Info:', message),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Default story - Shows pre-chat form first
|
|
49
|
+
*/
|
|
50
|
+
export const Default: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
config: baseConfig,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { ChatWidget } from './ChatWidget';
|
|
3
|
+
import { PreChatForm } from './PreChatForm';
|
|
4
|
+
import type { IChatConfig, IUser } from '../types';
|
|
5
|
+
|
|
6
|
+
interface ChatWidgetWrapperProps {
|
|
7
|
+
/**
|
|
8
|
+
* Base configuration for the chat widget (without user info and conversationId)
|
|
9
|
+
*/
|
|
10
|
+
config: Omit<IChatConfig, 'currentUser' | 'conversationId' | 'userId'> & {
|
|
11
|
+
currentUser?: Partial<IUser>;
|
|
12
|
+
conversationId?: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Custom className for the wrapper
|
|
16
|
+
*/
|
|
17
|
+
className?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Storage key prefix for persisting user data
|
|
20
|
+
* Defaults to 'xcelsior_chat'
|
|
21
|
+
*/
|
|
22
|
+
storageKeyPrefix?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Callback when user submits the pre-chat form
|
|
25
|
+
*/
|
|
26
|
+
onPreChatSubmit?: (user: IUser) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface StoredUserData {
|
|
30
|
+
name: string;
|
|
31
|
+
email: string;
|
|
32
|
+
conversationId: string;
|
|
33
|
+
timestamp: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generates a unique session-based ID
|
|
38
|
+
*/
|
|
39
|
+
function generateSessionId(): string {
|
|
40
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
41
|
+
return crypto.randomUUID();
|
|
42
|
+
}
|
|
43
|
+
// Fallback for older browsers
|
|
44
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Chat component that handles:
|
|
49
|
+
* - Automatic conversation ID generation
|
|
50
|
+
* - Pre-chat form for collecting user information
|
|
51
|
+
* - Session persistence in localStorage
|
|
52
|
+
*/
|
|
53
|
+
export function Chat({
|
|
54
|
+
config,
|
|
55
|
+
className = '',
|
|
56
|
+
storageKeyPrefix = 'xcelsior_chat',
|
|
57
|
+
onPreChatSubmit,
|
|
58
|
+
}: ChatWidgetWrapperProps) {
|
|
59
|
+
const [userInfo, setUserInfo] = useState<IUser | null>(null);
|
|
60
|
+
const [conversationId, setConversationId] = useState<string>('');
|
|
61
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
62
|
+
|
|
63
|
+
// Initialize user data from localStorage or generate new session
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const initializeSession = () => {
|
|
66
|
+
try {
|
|
67
|
+
// Check if user provided initial data
|
|
68
|
+
if (config.currentUser?.email && config.currentUser?.name) {
|
|
69
|
+
const convId = config.conversationId || generateSessionId();
|
|
70
|
+
|
|
71
|
+
const user: IUser = {
|
|
72
|
+
name: config.currentUser.name,
|
|
73
|
+
email: config.currentUser.email,
|
|
74
|
+
avatar: config.currentUser.avatar,
|
|
75
|
+
type: 'customer',
|
|
76
|
+
status: config.currentUser.status,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
setUserInfo(user);
|
|
80
|
+
setConversationId(convId);
|
|
81
|
+
setIsLoading(false);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Try to load from localStorage
|
|
86
|
+
const storedDataJson = localStorage.getItem(`${storageKeyPrefix}_user`);
|
|
87
|
+
|
|
88
|
+
if (storedDataJson) {
|
|
89
|
+
const storedData: StoredUserData = JSON.parse(storedDataJson);
|
|
90
|
+
|
|
91
|
+
// Check if session is still valid (24 hours)
|
|
92
|
+
const isExpired = Date.now() - storedData.timestamp > 24 * 60 * 60 * 1000;
|
|
93
|
+
|
|
94
|
+
if (!isExpired && storedData.email && storedData.name) {
|
|
95
|
+
// Restore session
|
|
96
|
+
const user: IUser = {
|
|
97
|
+
name: storedData.name,
|
|
98
|
+
email: storedData.email,
|
|
99
|
+
type: 'customer',
|
|
100
|
+
status: 'online',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
setUserInfo(user);
|
|
104
|
+
setConversationId(storedData.conversationId);
|
|
105
|
+
setIsLoading(false);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const convId = config.conversationId || generateSessionId();
|
|
111
|
+
setConversationId(convId);
|
|
112
|
+
|
|
113
|
+
// If we have partial user info, use it
|
|
114
|
+
if (config.currentUser?.email && config.currentUser?.name) {
|
|
115
|
+
const user: IUser = {
|
|
116
|
+
name: config.currentUser.name,
|
|
117
|
+
email: config.currentUser.email,
|
|
118
|
+
avatar: config.currentUser.avatar,
|
|
119
|
+
type: 'customer',
|
|
120
|
+
status: 'online',
|
|
121
|
+
};
|
|
122
|
+
setUserInfo(user);
|
|
123
|
+
}
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error('Error initializing chat session:', error);
|
|
126
|
+
// Generate fallback IDs
|
|
127
|
+
setConversationId(config.conversationId || generateSessionId());
|
|
128
|
+
} finally {
|
|
129
|
+
setIsLoading(false);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
initializeSession();
|
|
134
|
+
}, [config, storageKeyPrefix]);
|
|
135
|
+
|
|
136
|
+
// Handle pre-chat form submission
|
|
137
|
+
const handlePreChatSubmit = useCallback(
|
|
138
|
+
(name: string, email: string) => {
|
|
139
|
+
const convId = conversationId || generateSessionId();
|
|
140
|
+
|
|
141
|
+
const user: IUser = {
|
|
142
|
+
name,
|
|
143
|
+
email,
|
|
144
|
+
type: 'customer',
|
|
145
|
+
status: 'online',
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Store in localStorage
|
|
149
|
+
const storageData: StoredUserData = {
|
|
150
|
+
name,
|
|
151
|
+
email,
|
|
152
|
+
conversationId: convId,
|
|
153
|
+
timestamp: Date.now(),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
localStorage.setItem(`${storageKeyPrefix}_user`, JSON.stringify(storageData));
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.error('Error storing user data:', error);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
setUserInfo(user);
|
|
163
|
+
setConversationId(convId);
|
|
164
|
+
onPreChatSubmit?.(user);
|
|
165
|
+
},
|
|
166
|
+
[conversationId, storageKeyPrefix, onPreChatSubmit]
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// Show loading state
|
|
170
|
+
if (isLoading) {
|
|
171
|
+
return null; // Or you could show a loading spinner
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Show pre-chat form if user info is not available
|
|
175
|
+
if (!userInfo || !userInfo.email || !userInfo.name) {
|
|
176
|
+
return (
|
|
177
|
+
<PreChatForm
|
|
178
|
+
onSubmit={handlePreChatSubmit}
|
|
179
|
+
className={className}
|
|
180
|
+
initialName={config.currentUser?.name}
|
|
181
|
+
initialEmail={config.currentUser?.email}
|
|
182
|
+
/>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Show chat widget with complete config
|
|
187
|
+
const fullConfig: IChatConfig = {
|
|
188
|
+
...config,
|
|
189
|
+
conversationId,
|
|
190
|
+
currentUser: userInfo,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return <ChatWidget config={fullConfig} className={className} />;
|
|
194
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { IUser } from '../types';
|
|
2
|
+
|
|
3
|
+
interface ChatHeaderProps {
|
|
4
|
+
agent?: IUser;
|
|
5
|
+
onClose?: () => void;
|
|
6
|
+
onMinimize?: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ChatHeader({ agent, onClose, onMinimize }: ChatHeaderProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="bg-gradient-to-r from-blue-600 to-purple-600 text-white p-4 flex items-center justify-between">
|
|
12
|
+
<div className="flex items-center gap-3">
|
|
13
|
+
<div className="relative">
|
|
14
|
+
<div className="h-10 w-10 rounded-full bg-white/20 flex items-center justify-center text-lg font-medium">
|
|
15
|
+
{agent?.avatar ? (
|
|
16
|
+
<img
|
|
17
|
+
src={agent.avatar}
|
|
18
|
+
alt={agent.name}
|
|
19
|
+
className="h-10 w-10 rounded-full object-cover"
|
|
20
|
+
/>
|
|
21
|
+
) : (
|
|
22
|
+
'🎧'
|
|
23
|
+
)}
|
|
24
|
+
</div>
|
|
25
|
+
{agent?.status === 'online' && (
|
|
26
|
+
<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full bg-green-500 border-2 border-white" />
|
|
27
|
+
)}
|
|
28
|
+
</div>
|
|
29
|
+
<div>
|
|
30
|
+
<h3 className="font-semibold text-base">{agent?.name || 'Support Team'}</h3>
|
|
31
|
+
<p className="text-xs text-white/80">
|
|
32
|
+
{agent?.status === 'online'
|
|
33
|
+
? 'Online'
|
|
34
|
+
: agent?.status === 'away'
|
|
35
|
+
? 'Away'
|
|
36
|
+
: "We'll reply as soon as possible"}
|
|
37
|
+
</p>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div className="flex items-center gap-2">
|
|
42
|
+
{onMinimize && (
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
onClick={onMinimize}
|
|
46
|
+
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
|
47
|
+
aria-label="Minimize chat"
|
|
48
|
+
>
|
|
49
|
+
<svg
|
|
50
|
+
className="w-5 h-5"
|
|
51
|
+
fill="none"
|
|
52
|
+
viewBox="0 0 24 24"
|
|
53
|
+
stroke="currentColor"
|
|
54
|
+
aria-hidden="true"
|
|
55
|
+
>
|
|
56
|
+
<title>Minimize icon</title>
|
|
57
|
+
<path
|
|
58
|
+
strokeLinecap="round"
|
|
59
|
+
strokeLinejoin="round"
|
|
60
|
+
strokeWidth={2}
|
|
61
|
+
d="M20 12H4"
|
|
62
|
+
/>
|
|
63
|
+
</svg>
|
|
64
|
+
</button>
|
|
65
|
+
)}
|
|
66
|
+
{onClose && (
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={onClose}
|
|
70
|
+
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
|
71
|
+
aria-label="Close chat"
|
|
72
|
+
>
|
|
73
|
+
<svg
|
|
74
|
+
className="w-5 h-5"
|
|
75
|
+
fill="none"
|
|
76
|
+
viewBox="0 0 24 24"
|
|
77
|
+
stroke="currentColor"
|
|
78
|
+
aria-hidden="true"
|
|
79
|
+
>
|
|
80
|
+
<title>Close icon</title>
|
|
81
|
+
<path
|
|
82
|
+
strokeLinecap="round"
|
|
83
|
+
strokeLinejoin="round"
|
|
84
|
+
strokeWidth={2}
|
|
85
|
+
d="M6 18L18 6M6 6l12 12"
|
|
86
|
+
/>
|
|
87
|
+
</svg>
|
|
88
|
+
</button>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|