@riosst100/pwa-marketplace 3.2.8 → 3.3.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.
@@ -0,0 +1,345 @@
1
+ import { shape, string } from 'prop-types';
2
+ import { FormattedMessage, useIntl } from 'react-intl';
3
+
4
+ // import { useMessagesPage } from '@riosst100/pwa-marketplace/src/talons/MessagesPage/useMessagesPage';
5
+ import { useStyle } from '@magento/venia-ui/lib/classify';
6
+ import { StoreTitle } from '@magento/venia-ui/lib/components/Head';
7
+ import defaultClasses from '@riosst100/pwa-marketplace/src/components/Messages/messagesPage.module.css';
8
+ import cn from 'classnames';
9
+ import React, { useMemo, useState, useEffect } from 'react';
10
+ import ChatContent from '@riosst100/pwa-marketplace/src/components/LiveChat/chatContent';
11
+
12
+ const MessagesPage = props => {
13
+ const classes = useStyle(defaultClasses, props.classes);
14
+ // const { becomeSellerProps } = useMessagesPage(props);
15
+ const { formatMessage } = useIntl();
16
+
17
+ const [selectedThread, setSelectedThread] = useState(null);
18
+ const [replyText, setReplyText] = useState('');
19
+ const [newSubject, setNewSubject] = useState('');
20
+ const [newContent, setNewContent] = useState('');
21
+ const [isComposing, setIsComposing] = useState(false);
22
+ const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
23
+ const [selectLatestAfterSend, setSelectLatestAfterSend] = useState(false);
24
+ const [isSendingNew, setIsSendingNew] = useState(false);
25
+ const [isReplying, setIsReplying] = useState(false);
26
+ const [isDeleting, setIsDeleting] = useState(false);
27
+
28
+ const renderStatusBadge = (statusValue) => {
29
+ const s = Number(statusValue);
30
+ const map = {
31
+ 0: { label: formatMessage({ id: 'messages.status.closed', defaultMessage: 'Closed' }), bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' },
32
+ 1: { label: formatMessage({ id: 'messages.status.open', defaultMessage: 'Open' }), bg: 'bg-green-100', text: 'text-green-700', dot: 'bg-green-500' },
33
+ 2: { label: formatMessage({ id: 'messages.status.processing', defaultMessage: 'Processing' }), bg: 'bg-yellow-300/20', text: 'text-yellow-300', dot: 'bg-yellow-300' },
34
+ 3: { label: formatMessage({ id: 'messages.status.done', defaultMessage: 'Done' }), bg: 'bg-[#3d84ff1a]', text: 'text-[#3d84ff]', dot: 'bg-[#3d84ff]' }
35
+ };
36
+ const conf = map[s] || { label: 'Unknown', bg: 'bg-gray-200', text: 'text-gray-700', dot: 'bg-gray-500' };
37
+ return (
38
+ <span className={`inline-flex items-center justify-center gap-1 px-2 h-[24px] rounded-full text-[12px] font-medium ${conf.bg} ${conf.text} whitespace-nowrap`}>
39
+ <span className={`inline-block w-[8px] h-[8px] rounded-full ${conf.dot}`}></span>
40
+ {conf.label}
41
+ </span>
42
+ );
43
+ };
44
+
45
+ // Filter pesan yang terkait seller ini (perkiraan berdasarkan id terkait)
46
+ const threads = useMemo(() => {
47
+ const items = messages?.items || [];
48
+ if (!seller?.seller_id) return items;
49
+ return items.filter(t =>
50
+ t?.owner_id === seller.seller_id ||
51
+ t?.receiver_id === seller.seller_id ||
52
+ t?.sender_id === seller.seller_id
53
+ );
54
+ }, [messages, seller]);
55
+
56
+ const sortedThreads = useMemo(() => {
57
+ return [...threads].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
58
+ }, [threads]);
59
+
60
+ useEffect(() => {
61
+ if (!isComposing && sortedThreads?.length) {
62
+ const latest = sortedThreads[sortedThreads.length - 1];
63
+ if (!selectedThread) {
64
+ setSelectedThread(latest);
65
+ } else {
66
+ const stillThere = sortedThreads.find(t => t.message_id === selectedThread.message_id);
67
+ if (!stillThere) {
68
+ setSelectedThread(latest);
69
+ } else {
70
+ // Replace with the fresh thread data so new details from polling appear
71
+ setSelectedThread(stillThere);
72
+ if (selectLatestAfterSend) {
73
+ setSelectedThread(latest);
74
+ setSelectLatestAfterSend(false);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }, [sortedThreads, isComposing, selectedThread, selectLatestAfterSend]);
80
+
81
+ // Tandai sudah pernah load minimal sekali untuk mencegah flicker
82
+ useEffect(() => {
83
+ if (threads.length > 0 || (!loading && messages)) {
84
+ setHasLoadedOnce(true);
85
+ }
86
+ }, [threads.length, loading, messages]);
87
+
88
+ const chatData = useMemo(() => {
89
+ const details = selectedThread?.details?.items || [];
90
+ const sorted = [...details].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
91
+ const opening = selectedThread ? [{
92
+ message: selectedThread.description,
93
+ senderName: selectedThread.sender_name,
94
+ timeStamp: selectedThread.created_at ? new Date(selectedThread.created_at).toLocaleString() : '',
95
+ type: selectedThread.sender_name === seller?.name ? 'seller' : 'customer'
96
+ }] : [];
97
+ const mapped = sorted.map(d => ({
98
+ message: d.content,
99
+ senderName: d.sender_name,
100
+ timeStamp: new Date(d.created_at).toLocaleString(),
101
+ type: d.sender_name === seller?.name ? 'seller' : 'customer'
102
+ }));
103
+ return [...opening, ...mapped];
104
+ }, [selectedThread, seller]);
105
+
106
+ const handleSubmitReply = async (e) => {
107
+ e.preventDefault();
108
+ if (!selectedThread?.message_id || !replyText.trim()) return;
109
+ try {
110
+ setIsReplying(true);
111
+ const res = await onReply({ message_id: selectedThread.message_id, content: replyText.trim() });
112
+ // Optimistic local append so the reply appears instantly
113
+ const newDetail = {
114
+ content: res?.content || replyText.trim(),
115
+ sender_name: res?.sender_name || selectedThread?.sender_name || 'You',
116
+ sender_email: res?.sender_email || '',
117
+ receiver_name: res?.receiver_name || '',
118
+ is_read: true,
119
+ created_at: res?.created_at || new Date().toISOString()
120
+ };
121
+ const nextThread = {
122
+ ...selectedThread,
123
+ details: {
124
+ ...selectedThread.details,
125
+ items: [...(selectedThread.details?.items || []), newDetail],
126
+ total_count: (selectedThread.details?.total_count || 0) + 1
127
+ }
128
+ };
129
+ setSelectedThread(nextThread);
130
+ } finally {
131
+ setIsReplying(false);
132
+ }
133
+ setReplyText('');
134
+ };
135
+
136
+ const handleDeleteThread = async () => {
137
+ if (!selectedThread?.message_id) return;
138
+ try {
139
+ setIsDeleting(true);
140
+ await onDelete({ id: selectedThread.message_id });
141
+ } finally {
142
+ setIsDeleting(false);
143
+ }
144
+ };
145
+
146
+ const handleSubmitNewMessage = async (e) => {
147
+ e.preventDefault();
148
+ if (!newSubject.trim() || !newContent.trim()) return;
149
+ try {
150
+ setIsSendingNew(true);
151
+ await onSend({ subject: newSubject.trim(), content: newContent.trim(), seller_url: seller?.url_key });
152
+ } finally {
153
+ setIsSendingNew(false);
154
+ }
155
+ setNewSubject('');
156
+ setNewContent('');
157
+ setIsComposing(false);
158
+ setSelectLatestAfterSend(true);
159
+ };
160
+
161
+ return (
162
+ <div className={classes.rootContainer}>
163
+ <div className={cn(classes.leftContent, '!py-[60px]')}>
164
+ <StoreTitle>
165
+ {formatMessage({
166
+ id: 'messagesPage.title',
167
+ defaultMessage: 'Messages'
168
+ })}
169
+ </StoreTitle>
170
+ <div className={cn(classes.leftContentContainer, '')}>
171
+ <div class="auth-left">
172
+ <div className='px-3 py-2 border-b font-medium flex items-center justify-between'>
173
+ <span>{formatMessage({ id: 'messages.sidebar.title', defaultMessage: 'Messages' })}</span>
174
+ <button
175
+ type='button'
176
+ onClick={() => {
177
+ setIsComposing(true);
178
+ setSelectedThread(null);
179
+ }}
180
+ className='text-[12px] px-2 py-1 rounded border border-[#FF6E26] text-[#FF6E26] hover:bg-[#FF6E26] hover:text-white'
181
+ >
182
+ {formatMessage({ id: 'messages.compose.newMessage', defaultMessage: 'Send New Message' })}
183
+ </button>
184
+ </div>
185
+ <div className='max-h-[420px] overflow-y-auto'>
186
+ {(!isComposing && !hasLoadedOnce && loading && sortedThreads.length === 0) ? (
187
+ <div className='p-3 text-sm text-gray-500'>{formatMessage({ id: 'messages.loading', defaultMessage: 'Loading…' })}</div>
188
+ ) : (
189
+ sortedThreads.map(t => (
190
+ <button
191
+ key={t.message_id}
192
+ onClick={() => setSelectedThread(t)}
193
+ className={cn('w-full text-left px-3 py-2 border-b hover:bg-gray-50',
194
+ selectedThread?.message_id === t.message_id && 'bg-gray-100')}
195
+ >
196
+ <div className='flex items-center justify-between gap-2'>
197
+ <div className='text-[13px] font-semibold line-clamp-1'>{t.subject || formatMessage({ id: 'messages.noSubject', defaultMessage: 'No Subject' })}</div>
198
+ <div className='text-[12px] flex-shrink-0'>
199
+ {renderStatusBadge(t.status)}
200
+ </div>
201
+ </div>
202
+ <div className='text-[11px] text-gray-500 mt-1'>{formatMessage({ id: 'messages.createdAt', defaultMessage: 'Created:' })} {new Date(t.created_at).toLocaleString()}</div>
203
+ </button>
204
+ ))
205
+ )}
206
+ </div>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ <div className={classes.root}>
211
+ <div className="lg_border-2 lg_border-solid lg_border-subtle lg_rounded-md !border !border-gray-100 !rounded-lg">
212
+ {/* Compose new message (shown only when composing OR when no thread exists) */}
213
+ {(isComposing || sortedThreads.length === 0) && (
214
+ <form onSubmit={handleSubmitNewMessage} className='mb-3 rounded-[6px] p-1 flex flex-col gap-2'>
215
+ <div className='text-[13px] font-semibold flex items-center justify-between'>
216
+ <span>{formatMessage({ id: 'messages.compose.header', defaultMessage: 'Create New Message' })}</span>
217
+ {(sortedThreads.length > 0) && (
218
+ <button
219
+ type='button'
220
+ onClick={() => setIsComposing(false)}
221
+ className='text-[12px] px-2 py-1 rounded border text-gray-600 hover:bg-gray-50'
222
+ >
223
+ {formatMessage({ id: 'messages.compose.cancel', defaultMessage: 'Cancel' })}
224
+ </button>
225
+ )}
226
+ </div>
227
+ <input
228
+ type='text'
229
+ className='border border-gray-100 rounded px-3 py-2 outline-none focus:ring-2 focus:ring-[#FF6E26]'
230
+ placeholder={formatMessage({ id: 'messages.compose.subjectPlaceholder', defaultMessage: 'Subject' })}
231
+ value={newSubject}
232
+ onChange={(e) => setNewSubject(e.target.value)}
233
+ />
234
+ <textarea
235
+ rows={3}
236
+ className='border border-gray-100 rounded px-3 py-2 outline-none focus:ring-2 focus:ring-[#FF6E26]'
237
+ placeholder={formatMessage({ id: 'messages.compose.contentPlaceholder', defaultMessage: 'Write a message to the seller…' })}
238
+ value={newContent}
239
+ onChange={(e) => setNewContent(e.target.value)}
240
+ />
241
+ <div className='flex justify-end'>
242
+ <button
243
+ type='submit'
244
+ className={cn('px-4 py-2 rounded-[6px] text-white disabled:opacity-50 disabled:cursor-not-allowed', isSendingNew ? 'bg-gray-500 text-gray-700' : 'bg-[#FF6E26]')}
245
+ disabled={isSendingNew}
246
+ aria-busy={isSendingNew}
247
+ >
248
+ {isSendingNew
249
+ ? formatMessage({ id: 'messages.compose.sending', defaultMessage: 'Send Message...' })
250
+ : formatMessage({ id: 'messages.compose.send', defaultMessage: 'Send Message' })}
251
+ </button>
252
+ </div>
253
+ </form>
254
+ )}
255
+ {!isComposing && sortedThreads.length > 0 && (
256
+ <div className='flex items-center justify-between mb-2'>
257
+ <div>
258
+ <div className='text-[14px] font-semibold'>{selectedThread?.subject || formatMessage({ id: 'messages.noSubject', defaultMessage: 'No Subject' })}</div>
259
+ <div className='text-[12px] text-gray-600 flex items-center gap-2'>
260
+ <span>{formatMessage({ id: 'messages.detail.status', defaultMessage: 'Status:' })}</span>
261
+ {selectedThread && renderStatusBadge(selectedThread.status)}
262
+ </div>
263
+ </div>
264
+ {selectedThread && (
265
+ <button
266
+ onClick={handleDeleteThread}
267
+ disabled={isDeleting}
268
+ className='text-[12px] px-3 py-1 rounded border text-red-600 border-red-300 hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2'
269
+ >
270
+ {isDeleting && (
271
+ <span className='w-3 h-3 border-2 border-red-200 border-t-red-500 rounded-full animate-spin' aria-hidden='true'></span>
272
+ )}
273
+ {isDeleting
274
+ ? formatMessage({ id: 'messages.detail.deleting', defaultMessage: 'Deleting...' })
275
+ : formatMessage({ id: 'messages.detail.delete', defaultMessage: 'Delete Message' })}
276
+ </button>
277
+ )}
278
+ </div>
279
+ )}
280
+ {!isComposing && sortedThreads.length > 0 && (
281
+ <div className='border rounded-[6px] p-3 flex-1 min-h-[320px]'>
282
+ {selectedThread ? (
283
+ <ChatContent chatData={chatData} />
284
+ ) : (
285
+ <div className='text-sm text-gray-500'>{formatMessage({ id: 'messages.detail.selectPrompt', defaultMessage: 'Select a message to view the conversation.' })}</div>
286
+ )}
287
+ </div>
288
+ )}
289
+ {!isComposing && sortedThreads.length > 0 && selectedThread && (
290
+ <form onSubmit={handleSubmitReply} className='mt-3 flex gap-2'>
291
+ {(() => {
292
+ const statusNum = Number(selectedThread.status);
293
+ const isReplyEnabled = statusNum === 1 || statusNum === 2;
294
+ const disabledReason = statusNum === 0
295
+ ? formatMessage({ id: 'messages.reply.disabled.closed', defaultMessage: 'Conversation closed' })
296
+ : statusNum === 3
297
+ ? formatMessage({ id: 'messages.reply.disabled.done', defaultMessage: 'Conversation finished' })
298
+ : formatMessage({ id: 'messages.reply.disabled.generic', defaultMessage: 'Cannot send a reply' });
299
+ return (
300
+ <>
301
+ <input
302
+ type='text'
303
+ className='flex-1 border rounded px-3 py-2 outline-none focus:ring-2 focus:ring-[#FF6E26] disabled:bg-gray-100 disabled:text-gray-500'
304
+ placeholder={isReplyEnabled ? formatMessage({ id: 'messages.reply.placeholder', defaultMessage: 'Write a reply…' }) : disabledReason}
305
+ value={replyText}
306
+ onChange={(e) => setReplyText(e.target.value)}
307
+ disabled={!isReplyEnabled}
308
+ title={!isReplyEnabled ? disabledReason : undefined}
309
+ aria-disabled={!isReplyEnabled}
310
+ />
311
+ <button
312
+ type='submit'
313
+ disabled={!isReplyEnabled || isReplying}
314
+ title={!isReplyEnabled ? disabledReason : undefined}
315
+ className={cn('px-4 py-2 rounded-[6px] text-white disabled:opacity-50 disabled:cursor-not-allowed', isReplying ? 'bg-gray-500 text-gray-700' : 'bg-[#FF6E26]')}
316
+ aria-busy={isReplying}
317
+ >
318
+ {isReplying
319
+ ? formatMessage({ id: 'messages.reply.sending', defaultMessage: 'Sending...' })
320
+ : formatMessage({ id: 'messages.reply.send', defaultMessage: 'Send' })}
321
+ </button>
322
+ </>
323
+ );
324
+ })()}
325
+ </form>
326
+ )}
327
+ </div>
328
+ </div>
329
+ </div>
330
+ )
331
+ }
332
+
333
+ export default MessagesPage;
334
+
335
+ MessagesPage.defaultProps = {
336
+ signedInRedirectUrl: '/messages',
337
+ signInPageUrl: '/sign-in'
338
+ };
339
+
340
+ MessagesPage.propTypes = {
341
+ classes: shape({
342
+ root: string
343
+ }),
344
+ signedInRedirectUrl: string
345
+ };
@@ -0,0 +1 @@
1
+ export {default} from './messagesPage';
@@ -0,0 +1,50 @@
1
+ import { shape, string } from 'prop-types';
2
+ import { FormattedMessage, useIntl } from 'react-intl';
3
+
4
+ import { useMessagesPage } from '@riosst100/pwa-marketplace/src/talons/MessagesPage/useMessagesPage';
5
+ import { useStyle } from '@magento/venia-ui/lib/classify';
6
+ import { StoreTitle } from '@magento/venia-ui/lib/components/Head';
7
+ import defaultClasses from './messagesPage.module.css';
8
+ import cn from 'classnames';
9
+ import React, { useMemo, useState, useEffect } from 'react';
10
+ import Messages from '@riosst100/pwa-marketplace/src/components/Messages';
11
+
12
+ const MessagesPage = props => {
13
+ const classes = useStyle(defaultClasses, props.classes);
14
+ const { messagesData, messagesLoading, handleReplyMessage, handleDeleteMessage, handleSendMessage } = useMessagesPage(props);
15
+ const { formatMessage } = useIntl();
16
+
17
+ return (
18
+ <>
19
+ <StoreTitle>
20
+ {formatMessage({
21
+ id: 'messagesPage.title',
22
+ defaultMessage: 'Messages'
23
+ })}
24
+ </StoreTitle>
25
+ <div className={classes.rootContainer}>
26
+ <Messages
27
+ messages={messagesData?.customer?.sellerMessages}
28
+ onReply={handleReplyMessage}
29
+ onDelete={handleDeleteMessage}
30
+ onSend={handleSendMessage}
31
+ loading={messagesLoading}
32
+ />
33
+ </div>
34
+ </>
35
+ )
36
+ }
37
+
38
+ export default MessagesPage;
39
+
40
+ MessagesPage.defaultProps = {
41
+ signedInRedirectUrl: '/messages',
42
+ signInPageUrl: '/sign-in'
43
+ };
44
+
45
+ MessagesPage.propTypes = {
46
+ classes: shape({
47
+ root: string
48
+ }),
49
+ signedInRedirectUrl: string
50
+ };
@@ -0,0 +1,35 @@
1
+ .root {
2
+ composes: gap-y-md from global;
3
+ composes: grid from global;
4
+ /* composes: justify-center from global; */
5
+ composes: px-0 from global;
6
+ composes: py-md from global;
7
+ composes: text-center from global;
8
+ grid-template-columns: minmax(auto, 95%);
9
+ justify-content: right;
10
+ }
11
+
12
+ .leftContent {
13
+ composes: gap-y-md from global;
14
+ composes: grid from global;
15
+ composes: justify-center from global;
16
+ composes: px-0 from global;
17
+ composes: py-md from global;
18
+ composes: text-center from global;
19
+ grid-template-columns: minmax(auto, 50%);
20
+ }
21
+
22
+ .header {
23
+ composes: font-serif from global;
24
+ }
25
+
26
+ .leftContentContainer {
27
+ text-align: left;
28
+ width: 30%;
29
+ }
30
+
31
+ .rootContainer {
32
+ justify-content: center;
33
+ display: flex;
34
+ align-items: normal;
35
+ }
@@ -30,15 +30,42 @@ const RFQModalForm = (props) => {
30
30
 
31
31
  // Upload File with remove capability
32
32
  const [uploadedFiles, setUploadedFiles] = useState([]);
33
+ // Calculate safe raw-size limit to keep base64 payload under 2MB
34
+ const GRAPHQL_BASE64_LIMIT = 2 * 1024 * 1024;
35
+ const SAFE_RAW_LIMIT = Math.floor((GRAPHQL_BASE64_LIMIT * 3) / 4) - 8 * 1024;
36
+ const estimateBase64Size = size => Math.ceil((4 * size) / 3);
37
+
33
38
  const onDrop = useCallback((accepted) => {
34
- setUploadedFiles(prev => [...prev, ...accepted]);
35
- }, []);
39
+ const safe = [];
40
+ const rejected = [];
41
+ accepted.forEach(f => {
42
+ const est = estimateBase64Size(f.size || 0);
43
+ if (est <= GRAPHQL_BASE64_LIMIT) {
44
+ safe.push(f);
45
+ } else {
46
+ rejected.push({ name: f.name || f.path, size: f.size });
47
+ }
48
+ });
49
+ if (safe.length) {
50
+ setUploadedFiles(prev => [...prev, ...safe]);
51
+ }
52
+ if (rejected.length) {
53
+ addToast({
54
+ type: 'error',
55
+ message: formatMessage({
56
+ id: 'productFullDetail.rfqFileTooLarge',
57
+ defaultMessage: 'One or more files are too large. Max ~1.5MB each.'
58
+ }),
59
+ timeout: 4000
60
+ });
61
+ }
62
+ }, [addToast, formatMessage]);
36
63
 
37
64
  const {
38
65
  getRootProps,
39
66
  getInputProps,
40
67
  isDragActive
41
- } = useDropzone({ onDrop });
68
+ } = useDropzone({ onDrop, maxSize: SAFE_RAW_LIMIT, multiple: true });
42
69
 
43
70
  const handleRemoveFile = useCallback((index) => {
44
71
  setUploadedFiles(prev => prev.filter((_, i) => i !== index));
@@ -59,8 +86,60 @@ const RFQModalForm = (props) => {
59
86
  </div>
60
87
  ));
61
88
 
89
+ // Helper: convert File to base64 string (without data URL prefix)
90
+ const toBase64 = useCallback(
91
+ file =>
92
+ new Promise((resolve, reject) => {
93
+ try {
94
+ const reader = new FileReader();
95
+ reader.onload = () => {
96
+ const result = reader.result;
97
+ // reader.result is a Data URL: "data:<mime>;base64,<payload>"
98
+ const base64 = typeof result === 'string' && result.includes(',')
99
+ ? result.split(',')[1]
100
+ : '';
101
+ resolve(base64);
102
+ };
103
+ reader.onerror = reject;
104
+ reader.readAsDataURL(file);
105
+ } catch (e) {
106
+ reject(e);
107
+ }
108
+ }),
109
+ []
110
+ );
111
+
62
112
  const handleSubmit = useCallback(async values => {
63
- console.log('[RFQ] submit values', values);
113
+ // console.log('[RFQ] submit values', values);
114
+ // Prepare base64 conversions if any files were uploaded
115
+ let filesPayload = undefined;
116
+ if (uploadedFiles && uploadedFiles.length) {
117
+ // Hard guard: abort if any file would exceed limit after base64 encoding
118
+ const oversize = uploadedFiles.find(f => estimateBase64Size(f.size || 0) > GRAPHQL_BASE64_LIMIT);
119
+ if (oversize) {
120
+ addToast({
121
+ type: 'error',
122
+ message: formatMessage({
123
+ id: 'productFullDetail.rfqFileTooLargeSubmit',
124
+ defaultMessage: 'A selected file is too large (limit ~1.5MB).'
125
+ }),
126
+ timeout: 4000
127
+ });
128
+ return;
129
+ }
130
+ try {
131
+ const base64List = await Promise.all(
132
+ uploadedFiles.map(file => toBase64(file))
133
+ );
134
+ filesPayload = uploadedFiles.map((file, i) => ({
135
+ file_name: (file && (file.name || file.path)) || '',
136
+ file_type: (file && file.type) ? file.type : 'application/octet-stream',
137
+ base64: base64List[i] || ''
138
+ }));
139
+ } catch (e) {
140
+ console.error('[RFQ] file base64 conversion failed', e);
141
+ }
142
+ }
64
143
  const input = {
65
144
  product_id: productId,
66
145
  quantity: values.quote_qty ? Number(values.quote_qty) : undefined,
@@ -70,24 +149,16 @@ const RFQModalForm = (props) => {
70
149
  ? Number(values.quote_price_per_unit)
71
150
  : undefined,
72
151
  date_need_quote: startDate ? startDate.toISOString() : undefined,
73
- files: uploadedFiles.length
74
- ? uploadedFiles.map(file => ({
75
- file_name: (file && (file.name || file.path)) || '',
76
- // Browsers do not expose real file system paths; use name as a stable placeholder
77
- file_path: (file && (file.path || file.name)) || '',
78
- // Ensure a valid MIME string; default if missing
79
- file_type: (file && file.type) ? file.type : 'application/octet-stream'
80
- }))
81
- : undefined
152
+ files: filesPayload
82
153
  };
83
154
 
84
- console.log('[RFQ] input payload', input);
155
+ // console.log('[RFQ] input payload', input);
85
156
 
86
157
  Object.keys(input).forEach(key => input[key] === undefined && delete input[key]);
87
158
 
88
159
  try {
89
160
  const result = await handleCreateQuickRfq(input);
90
- console.log('[RFQ] mutation result', result);
161
+ // console.log('[RFQ] mutation result', result);
91
162
  addToast({
92
163
  type: 'success',
93
164
  message: formatMessage({
@@ -108,7 +179,7 @@ const RFQModalForm = (props) => {
108
179
  timeout: 4000
109
180
  });
110
181
  }
111
- }, [uploadedFiles, addToast, formatMessage, handleCreateQuickRfq, setOpen, startDate]);
182
+ }, [uploadedFiles, addToast, formatMessage, handleCreateQuickRfq, setOpen, startDate, toBase64]);
112
183
 
113
184
  return (
114
185
  <>
@@ -271,6 +342,7 @@ const RFQModalForm = (props) => {
271
342
  color="#f76b1c"
272
343
  />
273
344
  <div className='mt-2 text-blue-600'>Drop files or click here to upload</div>
345
+ <div className='mt-1 text-gray-500 text-xs'>Max size per file ~1.5MB (transport limit 2MB)</div>
274
346
  </div>
275
347
  {files && files.length > 0 && (
276
348
  <div className="wfp--dropzone__file-list mt-4">
@@ -288,7 +360,7 @@ const RFQModalForm = (props) => {
288
360
  }}
289
361
  type="button"
290
362
  onClick={() => {
291
- console.log('[RFQ] Cancel clicked, closing modal');
363
+ // console.log('[RFQ] Cancel clicked, closing modal');
292
364
  setOpen(false);
293
365
  }}
294
366
  >
@@ -307,7 +379,7 @@ const RFQModalForm = (props) => {
307
379
  }}
308
380
  type="button"
309
381
  onPress={() => {
310
- console.log('[RFQ] Submit pressed, calling formApi.submitForm()');
382
+ // console.log('[RFQ] Submit pressed, calling formApi.submitForm()');
311
383
  formApi.submitForm();
312
384
  }}
313
385
  disabled={createState?.loading}