shopify-chatbot-widget 0.9.7 → 0.9.8

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.
@@ -1,17 +1,19 @@
1
1
  import { useState, useRef, useEffect } from "react";
2
2
  import type { ChangeEvent } from "react";
3
- import { useTranslation } from "react-i18next";
4
- import { Input, Tooltip } from "antd";
3
+ // import { useTranslation } from "react-i18next";
4
+ import { message as antMessage } from "antd";
5
5
  import {
6
6
  PictureOutlined,
7
- SendOutlined,
8
7
  CloseCircleOutlined,
9
8
  AudioOutlined,
10
9
  StopOutlined,
11
10
  PlayCircleOutlined,
12
11
  PauseCircleOutlined,
12
+ LoadingOutlined,
13
+ ArrowUpOutlined,
13
14
  } from "@ant-design/icons";
14
15
  import WaveSurfer from "wavesurfer.js";
16
+ import "./ChatInput.css";
15
17
 
16
18
  interface ChatInputProps {
17
19
  userMessage: string;
@@ -24,6 +26,7 @@ interface ChatInputProps {
24
26
  audioBlob: Blob | null;
25
27
  setAudioBlob: (blob: Blob | null) => void;
26
28
  isSending: boolean;
29
+ colorCode: string;
27
30
  }
28
31
 
29
32
  const ChatInput: React.FC<ChatInputProps> = ({
@@ -37,35 +40,118 @@ const ChatInput: React.FC<ChatInputProps> = ({
37
40
  audioBlob,
38
41
  setAudioBlob,
39
42
  isSending,
43
+ colorCode,
40
44
  }) => {
41
- const { t } = useTranslation();
45
+ // const { t } = useTranslation();
42
46
  const [isRecording, setIsRecording] = useState(false);
43
47
  const [isPlaying, setIsPlaying] = useState(false);
48
+ const [recordingDuration, setRecordingDuration] = useState(0);
49
+ const [isDragging, setIsDragging] = useState(false);
44
50
 
45
51
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
46
52
  const fileInputRef = useRef<HTMLInputElement | null>(null);
47
53
  const audioChunks = useRef<Blob[]>([]);
48
54
  const waveformRef = useRef<HTMLDivElement | null>(null);
49
55
  const wavesurfer = useRef<WaveSurfer | null>(null);
56
+ const recordingIntervalRef = useRef<number | null>(null);
57
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
58
+
59
+ // Helper function to adjust color brightness
60
+ const adjustBrightness = (hex: string, percent: number) => {
61
+ const num = parseInt(hex.replace("#", ""), 16);
62
+ const amt = Math.round(2.55 * percent);
63
+ const R = (num >> 16) + amt;
64
+ const G = ((num >> 8) & 0x00ff) + amt;
65
+ const B = (num & 0x0000ff) + amt;
66
+ return (
67
+ "#" +
68
+ (
69
+ 0x1000000 +
70
+ (R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
71
+ (G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
72
+ (B < 255 ? (B < 1 ? 0 : B) : 255)
73
+ )
74
+ .toString(16)
75
+ .slice(1)
76
+ );
77
+ };
78
+
79
+ // Format duration as MM:SS
80
+ const formatDuration = (seconds: number) => {
81
+ const mins = Math.floor(seconds / 60);
82
+ const secs = seconds % 60;
83
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
84
+ };
85
+
86
+ // Validate image file
87
+ const validateImage = (file: File): boolean => {
88
+ const maxSize = 5 * 1024 * 1024; // 5MB
89
+ if (file.size > maxSize) {
90
+ antMessage.warning("Image size should be less than 5MB");
91
+ return false;
92
+ }
93
+ return true;
94
+ };
50
95
 
51
96
  const handleImageUpload = (event: ChangeEvent<HTMLInputElement>) => {
52
97
  const file = event.target.files?.[0];
53
- if (file) setSelectedImage(file);
98
+ if (file && validateImage(file)) {
99
+ setSelectedImage(file);
100
+ }
101
+ event.target.value = "";
54
102
  };
55
103
 
56
104
  const removeSelectedImage = () => {
57
105
  setSelectedImage(null);
58
106
  };
59
107
 
108
+ // Auto-resize textarea
109
+ const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
110
+ setUserMessage(e.target.value);
111
+
112
+ const textarea = e.target;
113
+ textarea.style.height = "auto";
114
+ const newHeight = Math.min(textarea.scrollHeight, 67.5); // Changed from 200 to 67.5 (3 lines)
115
+ textarea.style.height = `${newHeight}px`;
116
+ };
117
+
118
+ // Drag and drop handlers
119
+ const handleDragOver = (e: React.DragEvent) => {
120
+ e.preventDefault();
121
+ e.stopPropagation();
122
+ setIsDragging(true);
123
+ };
124
+
125
+ const handleDragLeave = (e: React.DragEvent) => {
126
+ e.preventDefault();
127
+ e.stopPropagation();
128
+ setIsDragging(false);
129
+ };
130
+
131
+ const handleDrop = (e: React.DragEvent) => {
132
+ e.preventDefault();
133
+ e.stopPropagation();
134
+ setIsDragging(false);
135
+
136
+ const file = e.dataTransfer.files?.[0];
137
+ if (file && file.type.startsWith("image/") && validateImage(file)) {
138
+ setSelectedImage(file);
139
+ } else if (file && !file.type.startsWith("image/")) {
140
+ antMessage.warning("Please drop an image file");
141
+ }
142
+ };
143
+
60
144
  const handleVoiceRecord = async () => {
61
145
  if (!navigator.mediaDevices?.getUserMedia) {
62
- alert(t("chatInput.voice.notSupported"));
146
+ antMessage.error("Voice recording is not supported in your browser");
63
147
  return;
64
148
  }
65
149
 
66
150
  if (!isRecording) {
67
151
  try {
68
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
152
+ const stream = await navigator.mediaDevices.getUserMedia({
153
+ audio: true,
154
+ });
69
155
  const mimeType = MediaRecorder.isTypeSupported("audio/webm")
70
156
  ? "audio/webm"
71
157
  : "audio/mp4";
@@ -73,6 +159,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
73
159
  const recorder = new MediaRecorder(stream, { mimeType });
74
160
  mediaRecorderRef.current = recorder;
75
161
  audioChunks.current = [];
162
+ setRecordingDuration(0);
163
+
164
+ recordingIntervalRef.current = setInterval(() => {
165
+ setRecordingDuration((prev) => prev + 1);
166
+ }, 1000);
76
167
 
77
168
  recorder.ondataavailable = (event) => {
78
169
  if (event.data.size > 0) audioChunks.current.push(event.data);
@@ -83,13 +174,16 @@ const ChatInput: React.FC<ChatInputProps> = ({
83
174
  setAudioBlob(blob);
84
175
  setIsRecording(false);
85
176
  stream.getTracks().forEach((track) => track.stop());
177
+ if (recordingIntervalRef.current) {
178
+ clearInterval(recordingIntervalRef.current);
179
+ }
86
180
  };
87
181
 
88
182
  recorder.start();
89
183
  setIsRecording(true);
90
184
  } catch (err) {
91
185
  console.error(err);
92
- alert(t("chatInput.voice.permissionError"));
186
+ antMessage.error("Microphone permission denied");
93
187
  }
94
188
  } else {
95
189
  mediaRecorderRef.current?.stop();
@@ -103,20 +197,22 @@ const ChatInput: React.FC<ChatInputProps> = ({
103
197
 
104
198
  wavesurfer.current = WaveSurfer.create({
105
199
  container: waveformRef.current,
106
- waveColor: "#ccc",
107
- progressColor: "#7F28F8",
108
- cursorWidth: 0,
109
- barWidth: 3,
110
- barHeight: 1.5,
111
- barGap: 2,
112
- height: 40,
200
+ waveColor: "#d1d5db",
201
+ progressColor: colorCode,
202
+ cursorWidth: 2,
203
+ cursorColor: colorCode,
204
+ barWidth: 2,
205
+ barHeight: 1,
206
+ barGap: 3,
207
+ height: 32,
113
208
  interact: true,
209
+ hideScrollbar: true,
114
210
  });
115
211
 
116
212
  wavesurfer.current.load(url);
117
213
  wavesurfer.current.on("finish", () => setIsPlaying(false));
118
214
  }
119
- }, [audioBlob]);
215
+ }, [audioBlob, colorCode]);
120
216
 
121
217
  const togglePlay = () => {
122
218
  if (wavesurfer.current) {
@@ -128,232 +224,230 @@ const ChatInput: React.FC<ChatInputProps> = ({
128
224
  const handleCancelRecording = () => {
129
225
  setIsRecording(false);
130
226
  setAudioBlob(null);
227
+ setRecordingDuration(0);
131
228
  wavesurfer.current?.destroy();
229
+ if (recordingIntervalRef.current) {
230
+ clearInterval(recordingIntervalRef.current);
231
+ }
232
+ };
233
+
234
+ useEffect(() => {
235
+ return () => {
236
+ if (recordingIntervalRef.current) {
237
+ clearInterval(recordingIntervalRef.current);
238
+ }
239
+ wavesurfer.current?.destroy();
240
+ };
241
+ }, []);
242
+
243
+ const hasContent = userMessage.trim() || selectedImage || audioBlob;
244
+
245
+ const hexToRgba = (hex: string, alpha: number) => {
246
+ const r = parseInt(hex.slice(1, 3), 16);
247
+ const g = parseInt(hex.slice(3, 5), 16);
248
+ const b = parseInt(hex.slice(5, 7), 16);
249
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
132
250
  };
133
251
 
134
252
  return (
135
253
  <div
136
- style={{
137
- display: "flex",
138
- alignItems: "center",
139
- backgroundColor: "white",
140
- border: "1px solid #d1d5db",
141
- borderRadius: "9999px",
142
- padding: "0.5rem 0.75rem",
143
- boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
144
- width: "95%",
145
- height: "56px",
146
- }}
254
+ className="chat-input-wrapper"
255
+ style={
256
+ {
257
+ "--primary-color": colorCode,
258
+ "--primary-hover": adjustBrightness(colorCode, -10),
259
+ "--primary-light": hexToRgba(colorCode, 0.1),
260
+ "--primary-shadow": hexToRgba(colorCode, 0.3),
261
+ } as React.CSSProperties
262
+ }
147
263
  >
148
- <button
149
- style={{
150
- color: "#6b7280",
151
- background: "none",
152
- border: "none",
153
- margin: "0 0.5rem",
154
- cursor: "pointer",
155
- padding: "0"
156
- }}
157
- onClick={() => fileInputRef.current?.click()}
158
- >
159
- {selectedImage ? (
160
- <div style={{ position: "relative" }}>
264
+ {/* Image Preview */}
265
+ {selectedImage && (
266
+ <div className="image-preview-container">
267
+ <div className="image-preview-content">
161
268
  <img
162
269
  src={URL.createObjectURL(selectedImage)}
163
- alt={t("chatInput.image.selectedAlt")}
164
- style={{
165
- width: "2rem",
166
- height: "2rem",
167
- borderRadius: "9999px",
168
- objectFit: "cover",
169
- padding: "0"
170
- }}
171
- />
172
- <CloseCircleOutlined
173
- style={{
174
- position: "absolute",
175
- top: "-0.5rem",
176
- right: "-0.5rem",
177
- fontSize: "14px",
178
- color: "#ef4444",
179
- background: "white",
180
- borderRadius: "9999px",
181
- cursor: "pointer",
182
- }}
183
- onClick={(e) => {
184
- e.stopPropagation();
185
- removeSelectedImage();
186
- }}
270
+ alt="Selected"
271
+ className="image-preview"
187
272
  />
273
+ <div className="image-preview-info">
274
+ <span className="image-preview-name">{selectedImage.name}</span>
275
+ <span className="image-preview-size">
276
+ {(selectedImage.size / 1024).toFixed(1)} KB
277
+ </span>
278
+ </div>
279
+ <button
280
+ className="image-preview-remove"
281
+ onClick={removeSelectedImage}
282
+ aria-label="Remove image"
283
+ >
284
+ <CloseCircleOutlined />
285
+ </button>
188
286
  </div>
189
- ) : (
190
- <PictureOutlined style={{ fontSize: "20px" }} />
191
- )}
192
- </button>
193
-
194
- <input
195
- disabled={isSending}
196
- type="file"
197
- accept="image/*"
198
- ref={fileInputRef}
199
- onChange={handleImageUpload}
200
- style={{ display: "none",padding: "0" }}
201
- />
202
-
203
- <Tooltip title={isRecording ? "Stop Recording" : "Send Audio"}>
204
- <button
205
- onClick={handleVoiceRecord}
206
- style={{
207
- color: isRecording ? "#ef4444" : "#6b7280",
208
- background: "none",
209
- border: "none",
210
- margin: "0 0.5rem",
211
- cursor: "pointer",
212
- padding: "0"
213
- }}
214
- >
215
- {isRecording ? (
216
- <StopOutlined style={{ fontSize: "20px" }} />
217
- ) : (
218
- <AudioOutlined style={{ fontSize: "20px" }} />
219
- )}
220
- </button>
221
- </Tooltip>
287
+ </div>
288
+ )}
222
289
 
290
+ {/* Main Input Container */}
223
291
  <div
224
- style={{
225
- flex: 1,
226
- margin: "0 0.5rem",
227
- minWidth: 0,
228
- maxWidth: "100%",
229
- display: "flex",
230
- alignItems: "center",
231
- }}
292
+ className={`chat-input-container ${isSending ? "disabled" : ""} ${
293
+ isDragging ? "dragging" : ""
294
+ }`}
295
+ onDragOver={handleDragOver}
296
+ onDragLeave={handleDragLeave}
297
+ onDrop={handleDrop}
232
298
  >
233
- {audioBlob ? (
234
- <div
235
- style={{
236
- display: "flex",
237
- alignItems: "center",
238
- width: "100%",
239
- backgroundColor: "#f3f4f6",
240
- padding: "0.5rem 1rem",
241
- borderRadius: "0.5rem",
242
- }}
243
- >
244
- <button
245
- style={{
246
- background: "none",
247
- border: "none",
248
- marginRight: "0.75rem",
249
- color: "#7f28f8",
250
- cursor: "pointer",
251
- padding: "0"
299
+ {/* Input Content Area */}
300
+ <div className="input-content">
301
+ {audioBlob ? (
302
+ // Audio Playback UI
303
+ <div className="audio-playback-container">
304
+ <button
305
+ className="audio-play-button"
306
+ onClick={togglePlay}
307
+ aria-label={isPlaying ? "Pause" : "Play"}
308
+ >
309
+ {isPlaying ? (
310
+ <PauseCircleOutlined className="play-icon" />
311
+ ) : (
312
+ <PlayCircleOutlined className="play-icon" />
313
+ )}
314
+ </button>
315
+ <div ref={waveformRef} className="waveform" />
316
+ <button
317
+ className="audio-cancel-button"
318
+ onClick={handleCancelRecording}
319
+ aria-label="Cancel recording"
320
+ >
321
+ <CloseCircleOutlined />
322
+ </button>
323
+ </div>
324
+ ) : isRecording ? (
325
+ // Recording UI
326
+ <div className="recording-container">
327
+ <div className="recording-indicator">
328
+ <span className="recording-dot"></span>
329
+ <span className="recording-text">Recording</span>
330
+ </div>
331
+ <span className="recording-duration">
332
+ {formatDuration(recordingDuration)}
333
+ </span>
334
+ </div>
335
+ ) : (
336
+ // Text Input
337
+ <textarea
338
+ ref={textareaRef}
339
+ value={userMessage}
340
+ onChange={handleTextareaChange}
341
+ placeholder="Enter Message ..."
342
+ className="text-input"
343
+ disabled={isSending}
344
+ rows={1}
345
+ onKeyDown={(e) => {
346
+ if (e.key === "Enter" && !e.shiftKey && !isSending) {
347
+ e.preventDefault();
348
+
349
+ const hasImage = !!selectedImage;
350
+ const hasText = !!userMessage.trim();
351
+
352
+ if (hasImage) {
353
+ handleSendImage(selectedImage, userMessage);
354
+ removeSelectedImage();
355
+ setUserMessage("");
356
+ if (textareaRef.current) {
357
+ textareaRef.current.style.height = "auto";
358
+ }
359
+ } else if (hasText) {
360
+ handleSendMessage();
361
+ setUserMessage("");
362
+ if (textareaRef.current) {
363
+ textareaRef.current.style.height = "auto";
364
+ }
365
+ }
366
+ }
252
367
  }}
253
- onClick={togglePlay}
368
+ maxLength={2000}
369
+ />
370
+ )}
371
+ </div>
372
+
373
+ {/* Bottom Action Bar */}
374
+ <div className="action-bar">
375
+ {/* Left side icons */}
376
+ <div className="action-icons-left">
377
+ <input
378
+ type="file"
379
+ accept="image/*"
380
+ ref={fileInputRef}
381
+ onChange={handleImageUpload}
382
+ style={{ display: "none" }}
383
+ />
384
+
385
+ <button
386
+ className="action-icon-button"
387
+ onClick={() => fileInputRef.current?.click()}
388
+ disabled={isSending || isRecording}
389
+ aria-label="Upload image"
254
390
  >
255
- {isPlaying ? (
256
- <PauseCircleOutlined style={{ fontSize: "24px" }} />
257
- ) : (
258
- <PlayCircleOutlined style={{ fontSize: "24px" }} />
259
- )}
391
+ <PictureOutlined />
392
+ </button>
393
+
394
+ <button
395
+ onClick={handleVoiceRecord}
396
+ className={`action-icon-button ${isRecording ? "recording" : ""}`}
397
+ disabled={isSending}
398
+ aria-label={isRecording ? "Stop recording" : "Record voice"}
399
+ >
400
+ {isRecording ? <StopOutlined /> : <AudioOutlined />}
260
401
  </button>
261
- <div ref={waveformRef} style={{ width: "100%", height: "2rem" }} />
262
- <CloseCircleOutlined
263
- style={{
264
- fontSize: "18px",
265
- color: "#ef4444",
266
- marginLeft: "0.75rem",
267
- cursor: "pointer",
268
- }}
269
- onClick={handleCancelRecording}
270
- />
271
- </div>
272
- ) : isRecording ? (
273
- <div
274
- style={{
275
- display: "flex",
276
- alignItems: "center",
277
- justifyContent: "center",
278
- backgroundColor: "#f3f4f6",
279
- padding: "0.5rem 1rem",
280
- borderRadius: "0.5rem",
281
- animation: "pulse 2s infinite",
282
-
283
- }}
284
- >
285
- <span>Recording</span>
286
402
  </div>
287
- ) : (
288
- <Input
289
- type="text"
290
- value={userMessage}
291
- onChange={(e) => setUserMessage(e.target.value)}
292
- placeholder={"Message..."}
293
- style={{
294
- width: "100%",
295
- border: "none",
296
- fontSize: "16px",
297
- margin:0,
298
- lineHeight: 1.5,
299
- color: "#374151",
300
- padding: "0"
301
- }}
302
- onKeyDown={(e) => {
303
- if (e.key === "Enter" && !isSending) {
304
- e.preventDefault();
305
-
306
- const hasImage = !!selectedImage;
307
- const hasText = !!userMessage.trim();
308
-
309
- if (hasImage) {
310
- handleSendImage(selectedImage, userMessage);
311
- removeSelectedImage();
312
- setUserMessage("");
313
- } else if (hasText) {
314
- handleSendMessage();
315
- setUserMessage("");
403
+
404
+ {/* Right side send button */}
405
+ <button
406
+ disabled={isSending || !hasContent}
407
+ onClick={() => {
408
+ const hasImage = !!selectedImage;
409
+ const hasText = !!userMessage.trim();
410
+ const hasAudio = !!audioBlob;
411
+
412
+ if (hasImage) {
413
+ handleSendImage(selectedImage, userMessage);
414
+ removeSelectedImage();
415
+ setUserMessage("");
416
+ if (textareaRef.current) {
417
+ textareaRef.current.style.height = "auto";
418
+ }
419
+ } else if (hasAudio) {
420
+ handleSendVoice(audioBlob);
421
+ handleCancelRecording();
422
+ } else if (hasText) {
423
+ handleSendMessage();
424
+ setUserMessage("");
425
+ if (textareaRef.current) {
426
+ textareaRef.current.style.height = "auto";
316
427
  }
317
428
  }
318
429
  }}
319
-
320
- />
430
+ className={`send-button ${hasContent && !isSending ? "active" : ""}`}
431
+ aria-label="Send message"
432
+ >
433
+ {isSending ? (
434
+ <LoadingOutlined className="send-icon spin" />
435
+ ) : (
436
+ <ArrowUpOutlined className="send-icon" />
437
+ )}
438
+ </button>
439
+ </div>
440
+
441
+ {/* Drag overlay */}
442
+ {isDragging && (
443
+ <div className="drag-overlay">
444
+ <PictureOutlined className="drag-icon" />
445
+ <span>Drop image here</span>
446
+ </div>
321
447
  )}
322
448
  </div>
323
-
324
- <button
325
- disabled={isSending}
326
- onClick={() => {
327
- const hasImage = !!selectedImage;
328
- const hasText = !!userMessage.trim();
329
- const hasAudio = !!audioBlob;
330
-
331
- if (hasImage) {
332
- handleSendImage(selectedImage, userMessage);
333
- removeSelectedImage();
334
- setUserMessage("");
335
- } else if (hasAudio) {
336
- handleSendVoice(audioBlob);
337
- handleCancelRecording();
338
- } else if (hasText) {
339
- handleSendMessage();
340
- setUserMessage("");
341
- }
342
- }}
343
- aria-label={t("chatInput.send")}
344
- style={{
345
- color: "#6b7280",
346
- background: "none",
347
- border: "none",
348
- margin: "0 0.5rem",
349
- cursor: "pointer",
350
- padding: "0"
351
- }}
352
- >
353
- <SendOutlined style={{ fontSize: "20px" }} />
354
- </button>
355
449
  </div>
356
450
  );
357
451
  };
358
452
 
359
- export default ChatInput;
453
+ export default ChatInput;
@@ -28,4 +28,20 @@
28
28
  opacity: 1;
29
29
  }
30
30
  }
31
-
31
+
32
+
33
+
34
+ @keyframes fadeSlideIn {
35
+ from {
36
+ opacity: 0;
37
+ transform: translateY(15px);
38
+ }
39
+ to {
40
+ opacity: 1;
41
+ transform: translateY(0);
42
+ }
43
+ }
44
+
45
+ .first-message {
46
+ animation: fadeSlideIn 0.5s ease-out;
47
+ }