@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.
@@ -0,0 +1,202 @@
1
+ import { useState } from 'react';
2
+
3
+ interface PreChatFormProps {
4
+ onSubmit: (name: string, email: string) => void;
5
+ className?: string;
6
+ initialName?: string;
7
+ initialEmail?: string;
8
+ }
9
+
10
+ /**
11
+ * PreChatForm component for collecting user information before starting chat
12
+ */
13
+ export function PreChatForm({
14
+ onSubmit,
15
+ className = '',
16
+ initialName = '',
17
+ initialEmail = '',
18
+ }: PreChatFormProps) {
19
+ const [name, setName] = useState(initialName);
20
+ const [email, setEmail] = useState(initialEmail);
21
+ const [errors, setErrors] = useState<{ name?: string; email?: string }>({});
22
+ const [isSubmitting, setIsSubmitting] = useState(false);
23
+
24
+ const validateForm = (): boolean => {
25
+ const newErrors: { name?: string; email?: string } = {};
26
+
27
+ // Validate name
28
+ if (!name.trim()) {
29
+ newErrors.name = 'Name is required';
30
+ } else if (name.trim().length < 2) {
31
+ newErrors.name = 'Name must be at least 2 characters';
32
+ }
33
+
34
+ // Validate email
35
+ if (!email.trim()) {
36
+ newErrors.email = 'Email is required';
37
+ } else {
38
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
39
+ if (!emailRegex.test(email)) {
40
+ newErrors.email = 'Please enter a valid email address';
41
+ }
42
+ }
43
+
44
+ setErrors(newErrors);
45
+ return Object.keys(newErrors).length === 0;
46
+ };
47
+
48
+ const handleSubmit = async (e: React.FormEvent) => {
49
+ e.preventDefault();
50
+
51
+ if (!validateForm()) {
52
+ return;
53
+ }
54
+
55
+ setIsSubmitting(true);
56
+ try {
57
+ onSubmit(name.trim(), email.trim());
58
+ } catch (error) {
59
+ console.error('Error submitting form:', error);
60
+ } finally {
61
+ setIsSubmitting(false);
62
+ }
63
+ };
64
+
65
+ return (
66
+ <div
67
+ className={`fixed bottom-4 right-4 z-50 flex flex-col bg-white dark:bg-gray-900 rounded-lg shadow-2xl overflow-hidden ${className}`}
68
+ style={{
69
+ width: '400px',
70
+ maxHeight: 'calc(100vh - 2rem)',
71
+ }}
72
+ >
73
+ {/* Header */}
74
+ <div className="bg-gradient-to-r from-blue-600 to-purple-600 text-white px-6 py-4">
75
+ <h2 className="text-lg font-semibold">Start a Conversation</h2>
76
+ <p className="text-sm text-blue-100 mt-1">
77
+ Please provide your details to continue
78
+ </p>
79
+ </div>
80
+
81
+ {/* Form */}
82
+ <form onSubmit={handleSubmit} className="p-6 space-y-5">
83
+ {/* Name Input */}
84
+ <div>
85
+ <label
86
+ htmlFor="chat-name"
87
+ className="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-200"
88
+ >
89
+ Name <span className="text-red-500">*</span>
90
+ </label>
91
+ <input
92
+ type="text"
93
+ id="chat-name"
94
+ value={name}
95
+ onChange={(e) => {
96
+ setName(e.target.value);
97
+ if (errors.name) {
98
+ setErrors((prev) => ({ ...prev, name: undefined }));
99
+ }
100
+ }}
101
+ className={`block w-full px-4 py-2.5 text-sm text-gray-900 bg-gray-50 rounded-lg border ${
102
+ errors.name
103
+ ? 'border-red-500 focus:ring-red-500 focus:border-red-500'
104
+ : 'border-gray-300 focus:ring-blue-500 focus:border-blue-500'
105
+ } dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500`}
106
+ placeholder="John Doe"
107
+ disabled={isSubmitting}
108
+ autoComplete="name"
109
+ />
110
+ {errors.name && (
111
+ <p className="mt-2 text-sm text-red-600 dark:text-red-500" role="alert">
112
+ {errors.name}
113
+ </p>
114
+ )}
115
+ </div>
116
+
117
+ {/* Email Input */}
118
+ <div>
119
+ <label
120
+ htmlFor="chat-email"
121
+ className="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-200"
122
+ >
123
+ Email <span className="text-red-500">*</span>
124
+ </label>
125
+ <input
126
+ type="email"
127
+ id="chat-email"
128
+ value={email}
129
+ onChange={(e) => {
130
+ setEmail(e.target.value);
131
+ if (errors.email) {
132
+ setErrors((prev) => ({ ...prev, email: undefined }));
133
+ }
134
+ }}
135
+ className={`block w-full px-4 py-2.5 text-sm text-gray-900 bg-gray-50 rounded-lg border ${
136
+ errors.email
137
+ ? 'border-red-500 focus:ring-red-500 focus:border-red-500'
138
+ : 'border-gray-300 focus:ring-blue-500 focus:border-blue-500'
139
+ } dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500`}
140
+ placeholder="john@example.com"
141
+ disabled={isSubmitting}
142
+ autoComplete="email"
143
+ />
144
+ {errors.email && (
145
+ <p className="mt-2 text-sm text-red-600 dark:text-red-500" role="alert">
146
+ {errors.email}
147
+ </p>
148
+ )}
149
+ </div>
150
+
151
+ {/* Submit Button */}
152
+ <button
153
+ type="submit"
154
+ disabled={isSubmitting}
155
+ className="w-full px-5 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
156
+ >
157
+ {isSubmitting ? (
158
+ <span className="flex items-center justify-center">
159
+ <svg
160
+ className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
161
+ xmlns="http://www.w3.org/2000/svg"
162
+ fill="none"
163
+ viewBox="0 0 24 24"
164
+ aria-label="Loading"
165
+ >
166
+ <title>Loading</title>
167
+ <circle
168
+ className="opacity-25"
169
+ cx="12"
170
+ cy="12"
171
+ r="10"
172
+ stroke="currentColor"
173
+ strokeWidth="4"
174
+ />
175
+ <path
176
+ className="opacity-75"
177
+ fill="currentColor"
178
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
179
+ />
180
+ </svg>
181
+ Starting Chat...
182
+ </span>
183
+ ) : (
184
+ 'Start Chat'
185
+ )}
186
+ </button>
187
+
188
+ {/* Privacy Notice */}
189
+ <p className="text-xs text-gray-500 dark:text-gray-400 text-center">
190
+ We respect your privacy. Your information will only be used to assist you.
191
+ </p>
192
+ </form>
193
+
194
+ {/* Footer */}
195
+ <div className="bg-gray-50 dark:bg-gray-950 px-4 py-2 text-center border-t border-gray-200 dark:border-gray-700">
196
+ <p className="text-xs text-gray-500 dark:text-gray-400">
197
+ Powered by <span className="font-semibold">Xcelsior Chat</span>
198
+ </p>
199
+ </div>
200
+ </div>
201
+ );
202
+ }
@@ -0,0 +1,31 @@
1
+ interface TypingIndicatorProps {
2
+ isTyping: boolean;
3
+ userName?: string;
4
+ }
5
+
6
+ export function TypingIndicator({ isTyping, userName }: TypingIndicatorProps) {
7
+ if (!isTyping) {
8
+ return null;
9
+ }
10
+
11
+ return (
12
+ <div className="px-4 py-2 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
13
+ <div className="flex items-center gap-2">
14
+ <div className="flex gap-1">
15
+ <span className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" />
16
+ <span
17
+ className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"
18
+ style={{ animationDelay: '0.1s' }}
19
+ />
20
+ <span
21
+ className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"
22
+ style={{ animationDelay: '0.2s' }}
23
+ />
24
+ </div>
25
+ <span className="text-xs text-gray-600 dark:text-gray-400">
26
+ {userName ? `${userName} is typing...` : 'Someone is typing...'}
27
+ </span>
28
+ </div>
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,134 @@
1
+ import { useState } from 'react';
2
+ import axios from 'axios';
3
+ import type { IFileUploadConfig, IUploadedFile } from '../types';
4
+
5
+ export interface UseFileUploadReturn {
6
+ uploadFile: (file: File) => Promise<IUploadedFile | null>;
7
+ isUploading: boolean;
8
+ uploadProgress: number;
9
+ error: Error | null;
10
+ canUpload: boolean;
11
+ }
12
+
13
+ export function useFileUpload(apiKey: string, config?: IFileUploadConfig): UseFileUploadReturn {
14
+ const [isUploading, setIsUploading] = useState(false);
15
+ const [uploadProgress, setUploadProgress] = useState(0);
16
+ const [error, setError] = useState<Error | null>(null);
17
+
18
+ const defaultConfig = {
19
+ maxFileSize: 10 * 1024 * 1024, // 10MB default
20
+ allowedTypes: [
21
+ 'image/jpeg',
22
+ 'image/jpg',
23
+ 'image/png',
24
+ 'image/gif',
25
+ 'image/webp',
26
+ 'application/pdf',
27
+ 'application/msword',
28
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
29
+ 'text/plain',
30
+ 'text/csv',
31
+ ],
32
+ };
33
+
34
+ const finalConfig = { ...defaultConfig, ...config };
35
+ const canUpload = !!config?.uploadUrl;
36
+
37
+ const validateFile = (file: File): string | null => {
38
+ if (!finalConfig.allowedTypes.includes(file.type)) {
39
+ return `File type ${file.type} is not supported. Allowed types: ${finalConfig.allowedTypes.join(', ')}`;
40
+ }
41
+
42
+ if (file.size > finalConfig.maxFileSize) {
43
+ return `File size ${(file.size / 1024 / 1024).toFixed(2)}MB exceeds the maximum allowed size of ${(finalConfig.maxFileSize / 1024 / 1024).toFixed(2)}MB`;
44
+ }
45
+
46
+ return null;
47
+ };
48
+
49
+ const uploadFile = async (file: File): Promise<IUploadedFile | null> => {
50
+ if (!config?.uploadUrl) {
51
+ const err = new Error('File upload URL is not configured');
52
+ setError(err);
53
+ throw err;
54
+ }
55
+
56
+ const validationError = validateFile(file);
57
+ if (validationError) {
58
+ const err = new Error(validationError);
59
+ setError(err);
60
+ throw err;
61
+ }
62
+
63
+ setIsUploading(true);
64
+ setUploadProgress(0);
65
+ setError(null);
66
+
67
+ try {
68
+ // Step 1: Get presigned upload URL from the API
69
+ const uploadUrlResponse = await axios.post(
70
+ config.uploadUrl,
71
+ {
72
+ fileName: file.name,
73
+ contentType: file.type,
74
+ fileSize: file.size,
75
+ },
76
+ {
77
+ headers: {
78
+ 'Content-Type': 'application/json',
79
+ 'x-api-key': apiKey || '',
80
+ ...config.headers,
81
+ },
82
+ }
83
+ );
84
+
85
+ const { uploadUrl, attachmentUrl } =
86
+ uploadUrlResponse.data.data || uploadUrlResponse.data;
87
+
88
+ if (!uploadUrl || !attachmentUrl) {
89
+ throw new Error('Failed to get upload URL from server');
90
+ }
91
+
92
+ // Step 2: Upload file directly to S3 using presigned URL
93
+ await axios.put(uploadUrl, file, {
94
+ headers: {
95
+ 'Content-Type': file.type,
96
+ },
97
+ onUploadProgress: (progressEvent) => {
98
+ if (progressEvent.total) {
99
+ const progress = Math.round(
100
+ (progressEvent.loaded * 100) / progressEvent.total
101
+ );
102
+ setUploadProgress(progress);
103
+ }
104
+ },
105
+ });
106
+
107
+ return {
108
+ url: attachmentUrl,
109
+ name: file.name,
110
+ size: file.size,
111
+ type: file.type,
112
+ markdown: file.type.startsWith('image/')
113
+ ? `![${file.name}](${attachmentUrl})`
114
+ : `[${file.name}](${attachmentUrl})`,
115
+ };
116
+ } catch (err) {
117
+ console.error('File upload failed:', err);
118
+ const error = err instanceof Error ? err : new Error('Upload failed');
119
+ setError(error);
120
+ throw error;
121
+ } finally {
122
+ setIsUploading(false);
123
+ setUploadProgress(0);
124
+ }
125
+ };
126
+
127
+ return {
128
+ uploadFile,
129
+ isUploading,
130
+ uploadProgress,
131
+ error,
132
+ canUpload,
133
+ };
134
+ }
@@ -0,0 +1,165 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import type { IMessage, IChatConfig } from '../types';
3
+ import type { UseWebSocketReturn } from './useWebSocket';
4
+ import { fetchMessages } from '../utils/api';
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
+ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig): UseMessagesReturn {
19
+ const [messages, setMessages] = useState<IMessage[]>([]);
20
+ const [isLoading, setIsLoading] = useState(false);
21
+ const [error, setError] = useState<Error | null>(null);
22
+ const [nextPageToken, setNextPageToken] = useState<string | undefined>(undefined);
23
+ const [hasMore, setHasMore] = useState(true);
24
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
25
+
26
+ // Extract stable references from config
27
+ const { httpApiUrl, conversationId, headers, onError, toast } = config;
28
+
29
+ const headersWithApiKey = useMemo(
30
+ () => ({
31
+ ...headers,
32
+ 'x-api-key': config.apiKey,
33
+ }),
34
+ [headers, config.apiKey]
35
+ );
36
+
37
+ // Fetch existing messages when conversationId changes
38
+ useEffect(() => {
39
+ const loadMessages = async () => {
40
+ // Only fetch if we have httpApiUrl and conversationId
41
+ if (!httpApiUrl || !conversationId) {
42
+ return;
43
+ }
44
+
45
+ setIsLoading(true);
46
+ setError(null);
47
+
48
+ try {
49
+ const result = await fetchMessages(
50
+ httpApiUrl,
51
+ { conversationId, limit: 20 },
52
+ headersWithApiKey
53
+ );
54
+
55
+ setMessages(result.data);
56
+ setNextPageToken(result.nextPageToken);
57
+ setHasMore(!!result.nextPageToken);
58
+ } catch (err) {
59
+ const error = err instanceof Error ? err : new Error('Failed to load messages');
60
+ setError(error);
61
+ onError?.(error);
62
+ toast?.error('Failed to load existing messages');
63
+ } finally {
64
+ setIsLoading(false);
65
+ }
66
+ };
67
+
68
+ loadMessages();
69
+ // Only re-run when conversationId or httpApiUrl changes
70
+ }, [conversationId, httpApiUrl, headersWithApiKey, onError, toast]);
71
+
72
+ // Extract onMessageReceived callback
73
+ const { onMessageReceived } = config;
74
+
75
+ // Listen for incoming messages from WebSocket
76
+ useEffect(() => {
77
+ if (websocket.lastMessage?.type === 'message' && websocket.lastMessage.data) {
78
+ const newMessage: IMessage = websocket.lastMessage.data;
79
+
80
+ if (conversationId && newMessage.conversationId !== conversationId) {
81
+ // Ignore messages for other conversations
82
+ return;
83
+ }
84
+ setMessages((prev) => {
85
+ // Avoid duplicates
86
+ if (prev.some((msg) => msg.id === newMessage.id)) {
87
+ return prev;
88
+ }
89
+ return [...prev, newMessage];
90
+ });
91
+
92
+ // Notify parent component about new message
93
+ onMessageReceived?.(newMessage);
94
+ }
95
+ }, [websocket.lastMessage, onMessageReceived, conversationId]);
96
+
97
+ const addMessage = useCallback((message: IMessage) => {
98
+ setMessages((prev) => {
99
+ // Avoid duplicates
100
+ if (prev.some((msg) => msg.id === message.id)) {
101
+ return prev;
102
+ }
103
+ return [...prev, message];
104
+ });
105
+ }, []);
106
+
107
+ const updateMessageStatus = useCallback((messageId: string, status: IMessage['status']) => {
108
+ setMessages((prev) => prev.map((msg) => (msg.id === messageId ? { ...msg, status } : msg)));
109
+ }, []);
110
+
111
+ const clearMessages = useCallback(() => {
112
+ setMessages([]);
113
+ }, []);
114
+
115
+ const loadMore = useCallback(async () => {
116
+ if (!hasMore || isLoadingMore || !httpApiUrl || !conversationId || !nextPageToken) {
117
+ return;
118
+ }
119
+
120
+ setIsLoadingMore(true);
121
+ setError(null);
122
+
123
+ try {
124
+ const result = await fetchMessages(
125
+ httpApiUrl,
126
+ {
127
+ conversationId,
128
+ limit: 20,
129
+ pageToken: nextPageToken,
130
+ },
131
+ headersWithApiKey
132
+ );
133
+
134
+ setMessages((prev) => [...result.data, ...prev]);
135
+ setNextPageToken(result.nextPageToken);
136
+ setHasMore(!!result.nextPageToken);
137
+ } catch (err) {
138
+ const error = err instanceof Error ? err : new Error('Failed to load more messages');
139
+ setError(error);
140
+ onError?.(error);
141
+ } finally {
142
+ setIsLoadingMore(false);
143
+ }
144
+ }, [
145
+ hasMore,
146
+ isLoadingMore,
147
+ httpApiUrl,
148
+ conversationId,
149
+ nextPageToken,
150
+ headersWithApiKey,
151
+ onError,
152
+ ]);
153
+
154
+ return {
155
+ messages,
156
+ addMessage,
157
+ updateMessageStatus,
158
+ clearMessages,
159
+ isLoading,
160
+ error,
161
+ loadMore,
162
+ hasMore,
163
+ isLoadingMore,
164
+ };
165
+ }
@@ -0,0 +1,33 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { UseWebSocketReturn } from './useWebSocket';
3
+
4
+ export interface UseTypingIndicatorReturn {
5
+ isTyping: boolean;
6
+ typingUsers: string[];
7
+ }
8
+
9
+ export function useTypingIndicator(websocket: UseWebSocketReturn): UseTypingIndicatorReturn {
10
+ const [typingUsers, setTypingUsers] = useState<string[]>([]);
11
+
12
+ useEffect(() => {
13
+ if (websocket.lastMessage?.type === 'typing' && websocket.lastMessage.data) {
14
+ const { userId, isTyping } = websocket.lastMessage.data;
15
+
16
+ if (isTyping) {
17
+ setTypingUsers((prev) => {
18
+ if (!prev.includes(userId)) {
19
+ return [...prev, userId];
20
+ }
21
+ return prev;
22
+ });
23
+ } else {
24
+ setTypingUsers((prev) => prev.filter((id) => id !== userId));
25
+ }
26
+ }
27
+ }, [websocket.lastMessage]);
28
+
29
+ return {
30
+ isTyping: typingUsers.length > 0,
31
+ typingUsers,
32
+ };
33
+ }