@aslaluroba/help-center-react 3.0.20 → 3.2.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/dist/components/ui/image-attachment.d.ts +9 -0
- package/dist/components/ui/image-preview-dialog.d.ts +10 -0
- package/dist/components/ui/index.d.ts +4 -0
- package/dist/core/api.d.ts +5 -2
- package/dist/index.css +1 -1
- package/dist/index.esm.js +4928 -551
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +4928 -551
- package/dist/index.js.map +1 -1
- package/dist/lib/types.d.ts +23 -0
- package/dist/services.esm.js +23 -16
- package/dist/services.esm.js.map +1 -1
- package/dist/services.js +23 -16
- package/dist/services.js.map +1 -1
- package/dist/ui/chatbot-popup/chat-window-screen/footer.d.ts +3 -2
- package/dist/ui/chatbot-popup/chat-window-screen/index.d.ts +3 -2
- package/dist/ui/help-popup.d.ts +3 -2
- package/package.json +5 -5
- package/src/.DS_Store +0 -0
- package/src/assets/icons/paperclip.svg +3 -0
- package/src/assets/icons/x.svg +4 -0
- package/src/components/ui/image-attachment.tsx +107 -0
- package/src/components/ui/image-preview-dialog.tsx +354 -0
- package/src/components/ui/index.ts +4 -0
- package/src/core/AblyService.ts +2 -2
- package/src/core/ApiService.ts +0 -2
- package/src/core/api.ts +81 -11
- package/src/lib/types.ts +110 -84
- package/src/ui/chatbot-popup/chat-window-screen/footer.tsx +259 -22
- package/src/ui/chatbot-popup/chat-window-screen/index.tsx +206 -101
- package/src/ui/help-center.tsx +52 -18
- package/src/ui/help-popup.tsx +11 -9
- package/src/ui/powered-by.tsx +10 -9
package/src/core/api.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TokenResponse } from '../lib/types';
|
|
1
|
+
import { TokenResponse, PresignUploadRequestDto, PresignUploadResponse, PresignDownloadResponse } from '../lib/types';
|
|
2
2
|
|
|
3
3
|
let getTokenFunction: (() => Promise<TokenResponse>) | undefined = undefined;
|
|
4
4
|
let baseUrl: string | null = null;
|
|
@@ -35,7 +35,6 @@ export async function getValidToken(forceRefresh = false): Promise<string> {
|
|
|
35
35
|
localStorage.setItem('chatbot-token', storedToken);
|
|
36
36
|
localStorage.setItem('chatbot-token-expiry', storedExpiry);
|
|
37
37
|
} catch (error) {
|
|
38
|
-
console.error('Failed to refresh token:', error);
|
|
39
38
|
throw error;
|
|
40
39
|
}
|
|
41
40
|
}
|
|
@@ -75,7 +74,6 @@ async function fetchWithAuth(url: string, options: RequestInit, retry = true): P
|
|
|
75
74
|
|
|
76
75
|
// Handle 401/403 with token refresh
|
|
77
76
|
if ((response.status === 401 || response.status === 403) && retry) {
|
|
78
|
-
console.warn('Token expired, refreshing...');
|
|
79
77
|
const newToken = await getValidToken(true);
|
|
80
78
|
headers.set('Authorization', `Bearer ${newToken}`);
|
|
81
79
|
options.headers = headers;
|
|
@@ -94,7 +92,6 @@ async function fetchWithAuth(url: string, options: RequestInit, retry = true): P
|
|
|
94
92
|
throw error;
|
|
95
93
|
}
|
|
96
94
|
} catch (error) {
|
|
97
|
-
console.error('Fetch error:', error);
|
|
98
95
|
throw error;
|
|
99
96
|
}
|
|
100
97
|
}
|
|
@@ -139,7 +136,7 @@ export async function apiRequest(
|
|
|
139
136
|
endpoint: string,
|
|
140
137
|
method = 'GET',
|
|
141
138
|
body: any = null,
|
|
142
|
-
options: { cache?: boolean; timeout?: number
|
|
139
|
+
options: { cache?: boolean; timeout?: number; language: string }
|
|
143
140
|
) {
|
|
144
141
|
if (!baseUrl) throw new Error('API not initialized');
|
|
145
142
|
|
|
@@ -165,6 +162,7 @@ export async function apiRequest(
|
|
|
165
162
|
headers: {
|
|
166
163
|
'Content-Type': 'application/json',
|
|
167
164
|
'Cache-Control': method === 'GET' ? 'max-age=30' : 'no-cache',
|
|
165
|
+
'Accept-Language': options.language,
|
|
168
166
|
},
|
|
169
167
|
body: body ? JSON.stringify(body) : null,
|
|
170
168
|
};
|
|
@@ -177,25 +175,36 @@ export async function apiRequest(
|
|
|
177
175
|
let errorMessage = 'API request failed';
|
|
178
176
|
|
|
179
177
|
try {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
errorMessage =
|
|
178
|
+
// Clone response before reading to avoid consuming the body
|
|
179
|
+
const errorResponse = response.clone();
|
|
180
|
+
const errorData = await errorResponse.json();
|
|
181
|
+
errorMessage = errorData.message || errorData.error || errorMessage;
|
|
182
|
+
} catch (parseError) {
|
|
183
|
+
// If JSON parsing fails, try to get text
|
|
184
|
+
try {
|
|
185
|
+
const errorResponse = response.clone();
|
|
186
|
+
const errorText = await errorResponse.text();
|
|
187
|
+
errorMessage = errorText || `HTTP ${response.status}: ${response.statusText}`;
|
|
188
|
+
} catch {
|
|
189
|
+
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
|
190
|
+
}
|
|
184
191
|
}
|
|
185
192
|
|
|
186
193
|
throw new Error(errorMessage);
|
|
187
194
|
}
|
|
188
195
|
|
|
189
196
|
// Cache successful GET responses
|
|
197
|
+
// Note: We clone before caching to avoid consuming the original response body
|
|
190
198
|
if (method === 'GET' && options.cache !== false) {
|
|
191
|
-
const responseData =
|
|
199
|
+
const responseData = response.clone();
|
|
192
200
|
const data = await responseData.json();
|
|
193
201
|
setCachedResponse(requestKey, { json: () => Promise.resolve(data) });
|
|
194
202
|
}
|
|
195
203
|
|
|
204
|
+
// Return the original response - it's body hasn't been consumed yet
|
|
205
|
+
// (we only cloned for caching, and the clone was consumed)
|
|
196
206
|
return response;
|
|
197
207
|
} catch (error) {
|
|
198
|
-
console.error(`API request failed for ${endpoint}:`, error);
|
|
199
208
|
throw error;
|
|
200
209
|
}
|
|
201
210
|
})();
|
|
@@ -205,3 +214,64 @@ export async function apiRequest(
|
|
|
205
214
|
|
|
206
215
|
return requestPromise;
|
|
207
216
|
}
|
|
217
|
+
|
|
218
|
+
export async function presignUpload(
|
|
219
|
+
chatSessionId: string,
|
|
220
|
+
file: File,
|
|
221
|
+
language: string
|
|
222
|
+
): Promise<PresignUploadResponse> {
|
|
223
|
+
const requestBody: PresignUploadRequestDto = {
|
|
224
|
+
name: file.name,
|
|
225
|
+
contentType: file.type,
|
|
226
|
+
sizeBytes: file.size,
|
|
227
|
+
pathData: {
|
|
228
|
+
type: 1,
|
|
229
|
+
chatSessionId: chatSessionId,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const response = await apiRequest('NewFile/presign-upload', 'POST', requestBody, { language });
|
|
234
|
+
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
throw new Error('Failed to get presigned upload URL');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return await response.json();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function presignDownload(fileId: string, language: string): Promise<PresignDownloadResponse> {
|
|
243
|
+
try {
|
|
244
|
+
const response = await apiRequest(`NewFile/${fileId}/presign-download`, 'GET', null, {
|
|
245
|
+
language,
|
|
246
|
+
cache: false, // Don't cache presigned URLs as they have expiration times
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// If response is not ok, apiRequest would have thrown an error already
|
|
250
|
+
// So we can safely read the JSON here
|
|
251
|
+
try {
|
|
252
|
+
return await response.json();
|
|
253
|
+
} catch (jsonError) {
|
|
254
|
+
// If JSON parsing fails, the response body might have been consumed
|
|
255
|
+
// or the response might not be valid JSON
|
|
256
|
+
if (jsonError instanceof Error) {
|
|
257
|
+
if (jsonError.message.includes('already read')) {
|
|
258
|
+
throw new Error(`Failed to parse response for file ${fileId}: Response body was already consumed`);
|
|
259
|
+
}
|
|
260
|
+
throw new Error(`Failed to parse response for file ${fileId}: ${jsonError.message}`);
|
|
261
|
+
}
|
|
262
|
+
throw new Error(`Failed to parse response for file ${fileId}`);
|
|
263
|
+
}
|
|
264
|
+
} catch (error) {
|
|
265
|
+
// Handle all types of errors
|
|
266
|
+
if (error instanceof Error) {
|
|
267
|
+
// If it's already a descriptive error from apiRequest, re-throw it
|
|
268
|
+
if (error.message.includes('API request failed') || error.message.includes('HTTP')) {
|
|
269
|
+
throw new Error(`Failed to get presigned download URL for file ${fileId}: ${error.message}`);
|
|
270
|
+
}
|
|
271
|
+
// Otherwise, re-throw with context
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
// Handle non-Error types
|
|
275
|
+
throw new Error(`Failed to get presigned download URL for file ${fileId}: ${String(error)}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -1,128 +1,154 @@
|
|
|
1
1
|
export interface HelpCenterProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
2
|
-
helpScreenId: string
|
|
3
|
-
primaryColor?: string
|
|
4
|
-
secondaryColor?: string
|
|
5
|
-
logoUrl?: string
|
|
2
|
+
helpScreenId: string;
|
|
3
|
+
primaryColor?: string;
|
|
4
|
+
secondaryColor?: string;
|
|
5
|
+
logoUrl?: string;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export interface Theme {
|
|
9
|
-
primary: string
|
|
10
|
-
secondary: string
|
|
11
|
-
text: string
|
|
12
|
-
background: string
|
|
13
|
-
error: string
|
|
14
|
-
success: string
|
|
9
|
+
primary: string;
|
|
10
|
+
secondary: string;
|
|
11
|
+
text: string;
|
|
12
|
+
background: string;
|
|
13
|
+
error: string;
|
|
14
|
+
success: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export interface UserData {
|
|
18
|
-
id: string
|
|
19
|
-
name: string
|
|
20
|
-
email: string
|
|
21
|
-
avatar?: string
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
email: string;
|
|
21
|
+
avatar?: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export interface Message {
|
|
25
|
-
id: number
|
|
26
|
-
senderType: number // 1: Customer, 2: Agent, 3: AI
|
|
27
|
-
messageContent: string
|
|
28
|
-
sentAt: Date
|
|
29
|
-
isSeen: boolean
|
|
25
|
+
id: number;
|
|
26
|
+
senderType: number; // 1: Customer, 2: Agent, 3: AI
|
|
27
|
+
messageContent: string;
|
|
28
|
+
sentAt: Date;
|
|
29
|
+
isSeen: boolean;
|
|
30
|
+
attachmentIds?: string[];
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export interface ChatSession {
|
|
33
|
-
id: string
|
|
34
|
-
messages: Message[]
|
|
35
|
-
participants: UserData[]
|
|
36
|
-
status: 'active' | 'closed'
|
|
37
|
-
createdAt: Date
|
|
38
|
-
updatedAt: Date
|
|
34
|
+
id: string;
|
|
35
|
+
messages: Message[];
|
|
36
|
+
participants: UserData[];
|
|
37
|
+
status: 'active' | 'closed';
|
|
38
|
+
createdAt: Date;
|
|
39
|
+
updatedAt: Date;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export interface ChatSessionData {
|
|
42
|
-
helpScreenId: string
|
|
43
|
-
user: UserData
|
|
44
|
-
initialMessage?: string
|
|
43
|
+
helpScreenId: string;
|
|
44
|
+
user: UserData;
|
|
45
|
+
initialMessage?: string;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export interface HelpCenterConfig {
|
|
48
|
-
baseUrl: string
|
|
49
|
-
getToken: () => Promise<{ token: string; expiresIn: number }
|
|
50
|
-
helpScreenId: string
|
|
51
|
-
user?: UserData
|
|
52
|
-
theme?: Partial<Theme
|
|
53
|
-
onMessageReceived?: (message: Message) => void
|
|
54
|
-
onSessionClosed?: () => void
|
|
55
|
-
onError?: (error: Error) => void
|
|
49
|
+
baseUrl: string;
|
|
50
|
+
getToken: () => Promise<{ token: string; expiresIn: number }>;
|
|
51
|
+
helpScreenId: string;
|
|
52
|
+
user?: UserData;
|
|
53
|
+
theme?: Partial<Theme>;
|
|
54
|
+
onMessageReceived?: (message: Message) => void;
|
|
55
|
+
onSessionClosed?: () => void;
|
|
56
|
+
onError?: (error: Error) => void;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
export interface HelpScreenOption {
|
|
59
|
-
id: string
|
|
60
|
-
title: string
|
|
61
|
-
paragraphs?: string[]
|
|
62
|
-
nestedOptions?: HelpScreenOption[]
|
|
63
|
-
chatWithUs?: boolean
|
|
60
|
+
id: string;
|
|
61
|
+
title: string;
|
|
62
|
+
paragraphs?: string[];
|
|
63
|
+
nestedOptions?: HelpScreenOption[];
|
|
64
|
+
chatWithUs?: boolean;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
export interface HelpScreen {
|
|
67
|
-
id: string
|
|
68
|
-
title: string
|
|
69
|
-
options: HelpScreenOption[]
|
|
68
|
+
id: string;
|
|
69
|
+
title: string;
|
|
70
|
+
options: HelpScreenOption[];
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
export interface ApiResponse<T> {
|
|
73
|
-
data: T
|
|
74
|
-
success: boolean
|
|
75
|
-
error?: string
|
|
74
|
+
data: T;
|
|
75
|
+
success: boolean;
|
|
76
|
+
error?: string;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
export interface TokenResponse {
|
|
79
|
-
token: string
|
|
80
|
-
expiresIn: number
|
|
80
|
+
token: string;
|
|
81
|
+
expiresIn: number;
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
export interface HelpScreenData {
|
|
84
|
-
id: string
|
|
85
|
-
tenantId: string
|
|
85
|
+
id: string;
|
|
86
|
+
tenantId: string;
|
|
86
87
|
tenant: {
|
|
87
|
-
id: string
|
|
88
|
-
name: string
|
|
89
|
-
key: string
|
|
90
|
-
}
|
|
91
|
-
title: string
|
|
92
|
-
description: string
|
|
93
|
-
options: Option[]
|
|
94
|
-
chatWithUs: boolean
|
|
88
|
+
id: string;
|
|
89
|
+
name: string;
|
|
90
|
+
key: string;
|
|
91
|
+
};
|
|
92
|
+
title: string;
|
|
93
|
+
description: string;
|
|
94
|
+
options: Option[];
|
|
95
|
+
chatWithUs: boolean;
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
export interface Option {
|
|
98
|
-
id: string
|
|
99
|
-
helpScreenId: string
|
|
100
|
-
parentOptionId: string | null
|
|
101
|
-
parentOption: Option | null
|
|
102
|
-
files: any[]
|
|
103
|
-
nestedOptions: Option[]
|
|
104
|
-
title: string
|
|
105
|
-
paragraphs: string[]
|
|
106
|
-
chatWithUs: boolean
|
|
107
|
-
assistantId?: string
|
|
99
|
+
id: string;
|
|
100
|
+
helpScreenId: string;
|
|
101
|
+
parentOptionId: string | null;
|
|
102
|
+
parentOption: Option | null;
|
|
103
|
+
files: any[];
|
|
104
|
+
nestedOptions: Option[];
|
|
105
|
+
title: string;
|
|
106
|
+
paragraphs: string[];
|
|
107
|
+
chatWithUs: boolean;
|
|
108
|
+
assistantId?: string;
|
|
108
109
|
assistant?: {
|
|
109
|
-
id: string
|
|
110
|
-
tenantId: string
|
|
110
|
+
id: string;
|
|
111
|
+
tenantId: string;
|
|
111
112
|
tenant: {
|
|
112
|
-
id: string
|
|
113
|
-
name: string
|
|
114
|
-
key: string
|
|
115
|
-
}
|
|
116
|
-
name: string
|
|
117
|
-
openAIAssistantId: string
|
|
118
|
-
greeting: string
|
|
119
|
-
closing: string
|
|
120
|
-
}
|
|
121
|
-
hasNestedOptions: boolean
|
|
122
|
-
order: number
|
|
113
|
+
id: string;
|
|
114
|
+
name: string;
|
|
115
|
+
key: string;
|
|
116
|
+
};
|
|
117
|
+
name: string;
|
|
118
|
+
openAIAssistantId: string;
|
|
119
|
+
greeting: string;
|
|
120
|
+
closing: string;
|
|
121
|
+
};
|
|
122
|
+
hasNestedOptions: boolean;
|
|
123
|
+
order: number;
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
export interface ReviewProps {
|
|
126
|
-
comment: string
|
|
127
|
-
rating: number
|
|
127
|
+
comment: string;
|
|
128
|
+
rating: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface PresignUploadRequestDto {
|
|
132
|
+
name: string;
|
|
133
|
+
contentType: string;
|
|
134
|
+
sizeBytes: number;
|
|
135
|
+
pathData: {
|
|
136
|
+
type: number;
|
|
137
|
+
chatSessionId: string;
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface PresignUploadResponse {
|
|
142
|
+
id: string;
|
|
143
|
+
uploadUrl: string;
|
|
144
|
+
path: string;
|
|
145
|
+
expiresAt: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface PresignDownloadResponse {
|
|
149
|
+
id: string;
|
|
150
|
+
name: string;
|
|
151
|
+
downloadUrl: string;
|
|
152
|
+
contentType: string;
|
|
153
|
+
expiresAt: string;
|
|
128
154
|
}
|
|
@@ -1,37 +1,274 @@
|
|
|
1
|
+
import React, { useRef, useState, useCallback } from 'react';
|
|
2
|
+
import axios from 'axios';
|
|
1
3
|
import { Button } from '@/components';
|
|
4
|
+
import { ImagePreviewDialog } from '@/components/ui';
|
|
2
5
|
import EnvelopeIcon from './../../../assets/icons/envelope.svg';
|
|
6
|
+
import PaperclipIcon from './../../../assets/icons/paperclip.svg';
|
|
7
|
+
import XIcon from './../../../assets/icons/x.svg';
|
|
3
8
|
import { useLocalTranslation } from '../../../useLocalTranslation';
|
|
9
|
+
import { presignUpload } from '@/core/api';
|
|
10
|
+
|
|
11
|
+
interface SelectedFileDto {
|
|
12
|
+
file: File;
|
|
13
|
+
previewUrl: string;
|
|
14
|
+
uploading: boolean;
|
|
15
|
+
uploadedId: string | null;
|
|
16
|
+
error: string | null;
|
|
17
|
+
}
|
|
4
18
|
|
|
5
19
|
interface ChatWindowFooterProps {
|
|
6
20
|
inputMessage: string;
|
|
7
21
|
setInputMessage: (e: string) => void;
|
|
8
|
-
|
|
9
|
-
handleSendMessage: () => void;
|
|
22
|
+
handleSendMessage: (attachmentIds: string[]) => void;
|
|
10
23
|
isLoading: boolean;
|
|
24
|
+
onEnsureSession: () => Promise<string>;
|
|
11
25
|
}
|
|
12
26
|
|
|
13
27
|
const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
|
|
14
|
-
const { t, dir } = useLocalTranslation();
|
|
15
|
-
|
|
28
|
+
const { t, dir, i18n } = useLocalTranslation();
|
|
29
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
30
|
+
const [selectedFiles, setSelectedFiles] = useState<SelectedFileDto[]>([]);
|
|
31
|
+
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
|
32
|
+
|
|
33
|
+
const handleAttachClick = useCallback(() => {
|
|
34
|
+
fileInputRef.current?.click();
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
38
|
+
const files = Array.from(e.target.files || []);
|
|
39
|
+
|
|
40
|
+
// Validate that all files are images
|
|
41
|
+
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
|
|
42
|
+
|
|
43
|
+
// Only image files are allowed
|
|
44
|
+
|
|
45
|
+
// Create preview URLs and add to selected files
|
|
46
|
+
const newFiles: SelectedFileDto[] = imageFiles.map((file) => ({
|
|
47
|
+
file,
|
|
48
|
+
previewUrl: URL.createObjectURL(file),
|
|
49
|
+
uploading: false,
|
|
50
|
+
uploadedId: null,
|
|
51
|
+
error: null,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
setSelectedFiles((prev) => [...prev, ...newFiles]);
|
|
55
|
+
|
|
56
|
+
// Clear the input
|
|
57
|
+
if (fileInputRef.current) {
|
|
58
|
+
fileInputRef.current.value = '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Start uploading immediately
|
|
62
|
+
await handleUploadFiles(newFiles);
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const handleUploadFiles = useCallback(
|
|
66
|
+
async (filesToUpload: SelectedFileDto[]) => {
|
|
67
|
+
// Get session ID
|
|
68
|
+
let sessionId: string;
|
|
69
|
+
try {
|
|
70
|
+
sessionId = await props.onEnsureSession();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// Mark all files as error
|
|
73
|
+
setSelectedFiles((prev) =>
|
|
74
|
+
prev.map((f) =>
|
|
75
|
+
filesToUpload.some((ftl) => ftl.previewUrl === f.previewUrl)
|
|
76
|
+
? { ...f, error: 'Failed to initialize session', uploading: false }
|
|
77
|
+
: f
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Upload each file
|
|
84
|
+
for (const fileDto of filesToUpload) {
|
|
85
|
+
try {
|
|
86
|
+
// Mark as uploading
|
|
87
|
+
setSelectedFiles((prev) =>
|
|
88
|
+
prev.map((f) => (f.previewUrl === fileDto.previewUrl ? { ...f, uploading: true, error: null } : f))
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Get presigned URL
|
|
92
|
+
const presignResponse = await presignUpload(sessionId, fileDto.file, i18n.language);
|
|
93
|
+
|
|
94
|
+
// Upload file to presigned URL using axios
|
|
95
|
+
// Important: Content-Type must match the file type (e.g., 'image/png'), not 'multipart/form-data'
|
|
96
|
+
const uploadResponse = await axios.put(presignResponse.uploadUrl, fileDto.file, {
|
|
97
|
+
headers: {
|
|
98
|
+
'Content-Type': fileDto.file.type,
|
|
99
|
+
},
|
|
100
|
+
onUploadProgress: () => {
|
|
101
|
+
// Upload progress tracking (silent)
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (uploadResponse.status !== 200 && uploadResponse.status !== 204) {
|
|
106
|
+
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Update with uploaded ID
|
|
110
|
+
setSelectedFiles((prev) =>
|
|
111
|
+
prev.map((f) =>
|
|
112
|
+
f.previewUrl === fileDto.previewUrl
|
|
113
|
+
? { ...f, uploading: false, uploadedId: presignResponse.id, error: null }
|
|
114
|
+
: f
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
setSelectedFiles((prev) =>
|
|
119
|
+
prev.map((f) =>
|
|
120
|
+
f.previewUrl === fileDto.previewUrl
|
|
121
|
+
? { ...f, uploading: false, error: 'Upload failed', uploadedId: null }
|
|
122
|
+
: f
|
|
123
|
+
)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
[props.onEnsureSession, i18n.language]
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const handleRemoveFile = useCallback((previewUrl: string) => {
|
|
132
|
+
setSelectedFiles((prev) => {
|
|
133
|
+
const fileToRemove = prev.find((f) => f.previewUrl === previewUrl);
|
|
134
|
+
if (fileToRemove) {
|
|
135
|
+
URL.revokeObjectURL(fileToRemove.previewUrl);
|
|
136
|
+
}
|
|
137
|
+
return prev.filter((f) => f.previewUrl !== previewUrl);
|
|
138
|
+
});
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
const handleSendMessageWithAttachments = useCallback(() => {
|
|
142
|
+
// Only allow sending if all files have finished uploading (either successfully or with error)
|
|
143
|
+
const hasUploadingFiles = selectedFiles.some((f) => f.uploading);
|
|
144
|
+
if (hasUploadingFiles) {
|
|
145
|
+
return; // Prevent sending if any files are still uploading
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Get all successfully uploaded file IDs
|
|
149
|
+
const attachmentIds = selectedFiles.filter((f) => f.uploadedId !== null).map((f) => f.uploadedId as string);
|
|
150
|
+
|
|
151
|
+
// Call the original send message with attachment IDs
|
|
152
|
+
props.handleSendMessage(attachmentIds);
|
|
153
|
+
|
|
154
|
+
// Clear selected files and revoke URLs
|
|
155
|
+
selectedFiles.forEach((f) => URL.revokeObjectURL(f.previewUrl));
|
|
156
|
+
setSelectedFiles([]);
|
|
157
|
+
}, [selectedFiles, props]);
|
|
158
|
+
|
|
159
|
+
// Check if any files are currently uploading
|
|
160
|
+
const hasUploadingFiles = selectedFiles.some((f) => f.uploading);
|
|
161
|
+
|
|
162
|
+
// Check if there are files that haven't finished (no uploadedId, no error, not uploading)
|
|
163
|
+
// This shouldn't happen in normal flow, but we check for safety
|
|
164
|
+
const hasPendingFiles = selectedFiles.some((f) => !f.uploading && f.uploadedId === null && f.error === null);
|
|
165
|
+
|
|
166
|
+
// Check if all files have errors (no successful uploads)
|
|
167
|
+
const hasSuccessfulUploads = selectedFiles.some((f) => f.uploadedId !== null);
|
|
168
|
+
const allFilesHaveErrors =
|
|
169
|
+
selectedFiles.length > 0 && !hasSuccessfulUploads && !hasUploadingFiles && !hasPendingFiles;
|
|
170
|
+
|
|
171
|
+
const isSendDisabled =
|
|
172
|
+
props.isLoading || props.inputMessage.trim() === '' || hasUploadingFiles || hasPendingFiles || allFilesHaveErrors;
|
|
173
|
+
|
|
174
|
+
const handleKeyDown = useCallback(
|
|
175
|
+
(e: React.KeyboardEvent) => {
|
|
176
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
if (!isSendDisabled) {
|
|
179
|
+
handleSendMessageWithAttachments();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
[isSendDisabled, handleSendMessageWithAttachments]
|
|
184
|
+
);
|
|
185
|
+
|
|
16
186
|
return (
|
|
17
|
-
<footer className='babylai-flex babylai-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
187
|
+
<footer className='babylai-flex babylai-flex-col babylai-gap-2 babylai-mx-4 md:babylai-mx-6'>
|
|
188
|
+
{selectedFiles.length > 0 && (
|
|
189
|
+
<div className='babylai-flex babylai-gap-2 babylai-flex-wrap babylai-p-2 babylai-bg-white dark:!babylai-bg-storm-dust-900 babylai-rounded-lg'>
|
|
190
|
+
{selectedFiles.map((file) => (
|
|
191
|
+
<div key={file.previewUrl} className='babylai-relative babylai-group'>
|
|
192
|
+
<img
|
|
193
|
+
src={file.previewUrl}
|
|
194
|
+
alt='Preview'
|
|
195
|
+
className='babylai-w-16 babylai-h-16 babylai-object-cover babylai-rounded-lg babylai-border babylai-border-black-white-200 babylai-cursor-pointer hover:babylai-opacity-80 babylai-transition-opacity'
|
|
196
|
+
onClick={() => setPreviewImage(file.previewUrl)}
|
|
197
|
+
role='button'
|
|
198
|
+
aria-label='Click to preview image'
|
|
199
|
+
/>
|
|
200
|
+
{file.uploading && (
|
|
201
|
+
<div className='babylai-absolute babylai-inset-0 babylai-flex babylai-items-center babylai-justify-center babylai-bg-black babylai-bg-opacity-50 babylai-rounded-lg'>
|
|
202
|
+
<div className='babylai-animate-spin babylai-rounded-full babylai-h-6 babylai-w-6 babylai-border-2 babylai-border-white babylai-border-t-transparent'></div>
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
{file.error && (
|
|
206
|
+
<div className='babylai-absolute babylai-inset-0 babylai-flex babylai-items-center babylai-justify-center babylai-bg-red-500 babylai-bg-opacity-70 babylai-rounded-lg'>
|
|
207
|
+
<span className='babylai-text-white babylai-text-xs'>Error</span>
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
<button
|
|
211
|
+
onClick={() => handleRemoveFile(file.previewUrl)}
|
|
212
|
+
className='babylai-absolute -babylai-top-2 -babylai-right-2 babylai-bg-red-500 babylai-text-white babylai-rounded-full babylai-w-5 babylai-h-5 babylai-flex babylai-items-center babylai-justify-center babylai-opacity-0 group-hover:babylai-opacity-100 babylai-transition-opacity'
|
|
213
|
+
type='button'
|
|
214
|
+
aria-label='Remove image'
|
|
215
|
+
>
|
|
216
|
+
<XIcon className='babylai-w-3 babylai-h-3' />
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
))}
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
<div className='babylai-flex babylai-items-center babylai-gap-2 babylai-relative babylai-rounded-full babylai-bg-white dark:!babylai-bg-storm-dust-900 babylai-py-1 babylai-px-2 md:babylai-py-3 md:babylai-px-4'>
|
|
224
|
+
<input
|
|
225
|
+
type='file'
|
|
226
|
+
ref={fileInputRef}
|
|
227
|
+
onChange={handleFileSelect}
|
|
228
|
+
accept='image/*'
|
|
229
|
+
multiple
|
|
230
|
+
className='babylai-hidden'
|
|
231
|
+
/>
|
|
232
|
+
<Button
|
|
233
|
+
variant='ghost'
|
|
234
|
+
size='icon'
|
|
235
|
+
onClick={handleAttachClick}
|
|
236
|
+
disabled={props.isLoading}
|
|
237
|
+
className='babylai-rounded-full babylai-w-8 babylai-h-8 babylai-text-black-white-500 hover:babylai-text-primary-500 hover:babylai-bg-transparent'
|
|
238
|
+
type='button'
|
|
239
|
+
>
|
|
240
|
+
<PaperclipIcon className='babylai-w-4 babylai-h-4' />
|
|
241
|
+
</Button>
|
|
242
|
+
<input
|
|
243
|
+
type='text'
|
|
244
|
+
value={props.inputMessage}
|
|
245
|
+
onChange={(e) => props.setInputMessage(e.target.value)}
|
|
246
|
+
onKeyDown={handleKeyDown}
|
|
247
|
+
placeholder={t('homeSdk.placeholder')}
|
|
248
|
+
className='babylai-flex-1 babylai-py-2 babylai-px-2 babylai-bg-transparent babylai-outline-none babylai-text-sm dark:babylai-text-white babylai-border-none'
|
|
249
|
+
/>
|
|
250
|
+
<Button
|
|
251
|
+
variant='default'
|
|
252
|
+
size='icon'
|
|
253
|
+
onClick={handleSendMessageWithAttachments}
|
|
254
|
+
disabled={isSendDisabled}
|
|
255
|
+
className='babylai-rounded-full babylai-bg-primary-500 babylai-hover:babylai-bg-purple-600 babylai-w-8 babylai-h-8 disabled:babylai-opacity-50'
|
|
256
|
+
type='button'
|
|
257
|
+
>
|
|
258
|
+
<EnvelopeIcon className={`babylai-w-4 babylai-h-4 ${dir === 'rtl' ? 'babylai-rotate-270' : ''}`} />
|
|
259
|
+
</Button>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{/* Image Preview Dialog */}
|
|
263
|
+
{previewImage && (
|
|
264
|
+
<ImagePreviewDialog
|
|
265
|
+
imageUrls={[previewImage]}
|
|
266
|
+
initialIndex={0}
|
|
267
|
+
isOpen={!!previewImage}
|
|
268
|
+
onClose={() => setPreviewImage(null)}
|
|
269
|
+
alt='Image preview'
|
|
270
|
+
/>
|
|
271
|
+
)}
|
|
35
272
|
</footer>
|
|
36
273
|
);
|
|
37
274
|
};
|