shopify-chatbot-widget 0.0.4
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/README.md +69 -0
- package/eslint.config.js +23 -0
- package/index.html +13 -0
- package/package-lock 2.json +4493 -0
- package/package.json +43 -0
- package/public/vite.svg +1 -0
- package/src/App.css +42 -0
- package/src/App.tsx +13 -0
- package/src/Chatbot/Chatbot.css +154 -0
- package/src/Chatbot/Chatbot.tsx +207 -0
- package/src/Chatbot/components/ChatInput.css +127 -0
- package/src/Chatbot/components/ChatInput.tsx +262 -0
- package/src/Chatbot/components/Messages/Addcart.tsx +70 -0
- package/src/Chatbot/components/Messages/AgentStatus.tsx +37 -0
- package/src/Chatbot/components/Messages/AssistantMesage.tsx +316 -0
- package/src/Chatbot/components/Messages/AudioMessage.tsx +123 -0
- package/src/Chatbot/components/Messages/CartWidget.tsx +116 -0
- package/src/Chatbot/components/Messages/ChatImage.tsx +46 -0
- package/src/Chatbot/components/Messages/ChatImageText.tsx +74 -0
- package/src/Chatbot/components/Messages/ChatImageTextAssis.tsx +72 -0
- package/src/Chatbot/components/Messages/ChatMessages.css +5 -0
- package/src/Chatbot/components/Messages/ChatMessages.tsx +284 -0
- package/src/Chatbot/components/Messages/Support.tsx +177 -0
- package/src/Chatbot/components/Messages/Typing.css +27 -0
- package/src/Chatbot/components/Messages/Typing.tsx +14 -0
- package/src/Chatbot/components/Messages/ZidAssistantMesage.tsx +142 -0
- package/src/Chatbot/components/Messenger/Messenger.tsx +521 -0
- package/src/Chatbot/components/Notification.tsx +27 -0
- package/src/Chatbot/components/Powered.css +16 -0
- package/src/Chatbot/components/Powered.tsx +13 -0
- package/src/Chatbot/components/Sessions/CreateSession.tsx +139 -0
- package/src/Chatbot/components/Sessions/RenderList.tsx +202 -0
- package/src/Chatbot/components/Suggestions.css +34 -0
- package/src/Chatbot/components/Suggestions.tsx +28 -0
- package/src/assets/react.svg +1 -0
- package/src/chatbot-widget/index.tsx +14 -0
- package/src/hooks/config.tsx +13 -0
- package/src/hooks/useCart.tsx +99 -0
- package/src/hooks/useChatbotAPI.tsx +160 -0
- package/src/hooks/useChatbotData.tsx +129 -0
- package/src/hooks/useSessionMessages.tsx +51 -0
- package/src/hooks/useWebsocke.tsx +45 -0
- package/src/i18n.tsx +30 -0
- package/src/index.css +1 -0
- package/src/main.tsx +10 -0
- package/src/types/chatbot.ts +38 -0
- package/src/utils/cookies.tsx +14 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +27 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +25 -0
- package/vite.config.ts +18 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from "react";
|
|
2
|
+
import type { ChangeEvent } from "react";
|
|
3
|
+
import { useTranslation } from "react-i18next";
|
|
4
|
+
import { Input, Tooltip } from "antd";
|
|
5
|
+
import {
|
|
6
|
+
PictureOutlined,
|
|
7
|
+
SendOutlined,
|
|
8
|
+
CloseCircleOutlined,
|
|
9
|
+
AudioOutlined,
|
|
10
|
+
StopOutlined,
|
|
11
|
+
PlayCircleOutlined,
|
|
12
|
+
PauseCircleOutlined,
|
|
13
|
+
} from "@ant-design/icons";
|
|
14
|
+
import WaveSurfer from "wavesurfer.js";
|
|
15
|
+
import "./ChatInput.css"; // <-- Import your raw CSS
|
|
16
|
+
|
|
17
|
+
interface ChatInputProps {
|
|
18
|
+
userMessage: string;
|
|
19
|
+
setUserMessage: React.Dispatch<React.SetStateAction<string>>;
|
|
20
|
+
handleSendMessage: () => void;
|
|
21
|
+
handleSendImage: (file: File, message: string) => void;
|
|
22
|
+
handleSendVoice: (audio: Blob) => void;
|
|
23
|
+
selectedImage: File | null;
|
|
24
|
+
setSelectedImage: (file: File | null) => void;
|
|
25
|
+
audioBlob: Blob | null;
|
|
26
|
+
setAudioBlob: (blob: Blob | null) => void;
|
|
27
|
+
isSending: boolean;
|
|
28
|
+
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ChatInput: React.FC<ChatInputProps> = ({
|
|
32
|
+
userMessage,
|
|
33
|
+
setUserMessage,
|
|
34
|
+
handleSendMessage,
|
|
35
|
+
handleSendImage,
|
|
36
|
+
handleSendVoice,
|
|
37
|
+
selectedImage,
|
|
38
|
+
setSelectedImage,
|
|
39
|
+
audioBlob,
|
|
40
|
+
setAudioBlob,
|
|
41
|
+
isSending
|
|
42
|
+
}) => {
|
|
43
|
+
const { t } = useTranslation();
|
|
44
|
+
const [isRecording, setIsRecording] = useState(false);
|
|
45
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
46
|
+
|
|
47
|
+
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
48
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
49
|
+
const audioChunks = useRef<Blob[]>([]);
|
|
50
|
+
const waveformRef = useRef<HTMLDivElement | null>(null);
|
|
51
|
+
const wavesurfer = useRef<WaveSurfer | null>(null);
|
|
52
|
+
|
|
53
|
+
const handleImageUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
|
54
|
+
const file = event.target.files?.[0];
|
|
55
|
+
if (file) setSelectedImage(file);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const removeSelectedImage = () => {
|
|
59
|
+
setSelectedImage(null);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleVoiceRecord = async () => {
|
|
63
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
64
|
+
alert(t("chatInput.voice.notSupported"));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!isRecording) {
|
|
69
|
+
try {
|
|
70
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
71
|
+
const mimeType = MediaRecorder.isTypeSupported("audio/webm")
|
|
72
|
+
? "audio/webm"
|
|
73
|
+
: "audio/mp4";
|
|
74
|
+
|
|
75
|
+
const recorder = new MediaRecorder(stream, { mimeType });
|
|
76
|
+
mediaRecorderRef.current = recorder;
|
|
77
|
+
audioChunks.current = [];
|
|
78
|
+
|
|
79
|
+
recorder.ondataavailable = (event) => {
|
|
80
|
+
if (event.data.size > 0) audioChunks.current.push(event.data);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
recorder.onstop = () => {
|
|
84
|
+
const blob = new Blob(audioChunks.current, { type: mimeType });
|
|
85
|
+
setAudioBlob(blob);
|
|
86
|
+
setIsRecording(false);
|
|
87
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
recorder.start();
|
|
91
|
+
setIsRecording(true);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.error(err);
|
|
94
|
+
alert(t("chatInput.voice.permissionError"));
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
mediaRecorderRef.current?.stop();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (audioBlob && waveformRef.current) {
|
|
103
|
+
wavesurfer.current?.destroy();
|
|
104
|
+
const url = URL.createObjectURL(audioBlob);
|
|
105
|
+
|
|
106
|
+
wavesurfer.current = WaveSurfer.create({
|
|
107
|
+
container: waveformRef.current,
|
|
108
|
+
waveColor: "#ccc",
|
|
109
|
+
progressColor: "#7F28F8",
|
|
110
|
+
cursorWidth: 0,
|
|
111
|
+
barWidth: 3,
|
|
112
|
+
barHeight: 1.5,
|
|
113
|
+
barGap: 2,
|
|
114
|
+
height: 40,
|
|
115
|
+
interact: true,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
wavesurfer.current.load(url);
|
|
119
|
+
wavesurfer.current.on("finish", () => setIsPlaying(false));
|
|
120
|
+
}
|
|
121
|
+
}, [audioBlob]);
|
|
122
|
+
|
|
123
|
+
const togglePlay = () => {
|
|
124
|
+
if (wavesurfer.current) {
|
|
125
|
+
wavesurfer.current.playPause();
|
|
126
|
+
setIsPlaying((prev) => !prev);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleCancelRecording = () => {
|
|
131
|
+
setIsRecording(false);
|
|
132
|
+
setAudioBlob(null);
|
|
133
|
+
wavesurfer.current?.destroy();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className="chat-input-container">
|
|
138
|
+
<button
|
|
139
|
+
className="chat-icon-button"
|
|
140
|
+
onClick={() => fileInputRef.current?.click()}
|
|
141
|
+
>
|
|
142
|
+
{selectedImage ? (
|
|
143
|
+
<div className="image-preview-wrapper">
|
|
144
|
+
<img
|
|
145
|
+
src={URL.createObjectURL(selectedImage)}
|
|
146
|
+
alt={t("chatInput.image.selectedAlt")}
|
|
147
|
+
className="image-preview"
|
|
148
|
+
/>
|
|
149
|
+
<CloseCircleOutlined
|
|
150
|
+
className="remove-image-icon"
|
|
151
|
+
onClick={(e) => {
|
|
152
|
+
e.stopPropagation();
|
|
153
|
+
removeSelectedImage();
|
|
154
|
+
}}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
) : (
|
|
158
|
+
<PictureOutlined style={{ fontSize: "20px" }} />
|
|
159
|
+
)}
|
|
160
|
+
</button>
|
|
161
|
+
|
|
162
|
+
<input
|
|
163
|
+
disabled={isSending}
|
|
164
|
+
type="file"
|
|
165
|
+
accept="image/*"
|
|
166
|
+
ref={fileInputRef}
|
|
167
|
+
onChange={handleImageUpload}
|
|
168
|
+
className="hidden-input"
|
|
169
|
+
/>
|
|
170
|
+
|
|
171
|
+
<Tooltip title={isRecording ? "Stop Recording" : "Send Audio"}>
|
|
172
|
+
<button
|
|
173
|
+
className={`chat-icon-button ${isRecording ? "recording" : ""}`}
|
|
174
|
+
onClick={handleVoiceRecord}
|
|
175
|
+
>
|
|
176
|
+
{isRecording ? (
|
|
177
|
+
<StopOutlined style={{ fontSize: "20px" }} />
|
|
178
|
+
) : (
|
|
179
|
+
<AudioOutlined style={{ fontSize: "20px" }} />
|
|
180
|
+
)}
|
|
181
|
+
</button>
|
|
182
|
+
</Tooltip>
|
|
183
|
+
|
|
184
|
+
<div className="chat-input-wrapper">
|
|
185
|
+
{audioBlob ? (
|
|
186
|
+
<div className="audio-preview">
|
|
187
|
+
<button className="audio-play-button" onClick={togglePlay}>
|
|
188
|
+
{isPlaying ? (
|
|
189
|
+
<PauseCircleOutlined style={{ fontSize: "24px" }} />
|
|
190
|
+
) : (
|
|
191
|
+
<PlayCircleOutlined style={{ fontSize: "24px" }} />
|
|
192
|
+
)}
|
|
193
|
+
</button>
|
|
194
|
+
<div ref={waveformRef} className="waveform" />
|
|
195
|
+
<CloseCircleOutlined
|
|
196
|
+
className="remove-audio-icon"
|
|
197
|
+
onClick={handleCancelRecording}
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
) : isRecording ? (
|
|
201
|
+
<div className="recording-indicator">
|
|
202
|
+
<span>{'Recording'}</span>
|
|
203
|
+
</div>
|
|
204
|
+
) : (
|
|
205
|
+
<Input
|
|
206
|
+
type="text"
|
|
207
|
+
className="text-input"
|
|
208
|
+
disabled={isSending}
|
|
209
|
+
value={userMessage}
|
|
210
|
+
onChange={(e) => setUserMessage(e.target.value)}
|
|
211
|
+
placeholder={'Message...'}
|
|
212
|
+
onKeyDown={(e) => {
|
|
213
|
+
if (e.key === "Enter") {
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
|
|
216
|
+
if (selectedImage) {
|
|
217
|
+
handleSendImage(selectedImage, userMessage);
|
|
218
|
+
removeSelectedImage();
|
|
219
|
+
setUserMessage("");
|
|
220
|
+
} else if (userMessage.trim()) {
|
|
221
|
+
handleSendMessage();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
/>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<button
|
|
233
|
+
className="chat-icon-button"
|
|
234
|
+
disabled={isSending}
|
|
235
|
+
|
|
236
|
+
onClick={() => {
|
|
237
|
+
const hasImage = !!selectedImage;
|
|
238
|
+
const hasText = !!userMessage.trim();
|
|
239
|
+
const hasAudio = !!audioBlob;
|
|
240
|
+
|
|
241
|
+
if (hasImage) {
|
|
242
|
+
handleSendImage(selectedImage, userMessage); // ✅ send image + text together
|
|
243
|
+
removeSelectedImage();
|
|
244
|
+
setUserMessage(""); // ✅ clear text to avoid double-send
|
|
245
|
+
} else if (hasAudio) {
|
|
246
|
+
handleSendVoice(audioBlob);
|
|
247
|
+
handleCancelRecording();
|
|
248
|
+
} else if (hasText) {
|
|
249
|
+
handleSendMessage(); // ✅ only send if no image/audio
|
|
250
|
+
}
|
|
251
|
+
}}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
aria-label={t("chatInput.send")}
|
|
255
|
+
>
|
|
256
|
+
<SendOutlined style={{ fontSize: "20px" }} />
|
|
257
|
+
</button>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export default ChatInput;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
interface AddToCartConfirmationProps {
|
|
4
|
+
message: string;
|
|
5
|
+
colorCode:string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const AddToCartConfirmation: React.FC<AddToCartConfirmationProps> = ({ message,colorCode }) => {
|
|
9
|
+
|
|
10
|
+
const formatTextWithLinks = (text: string): React.ReactNode[] => {
|
|
11
|
+
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
|
12
|
+
return text?.split(urlRegex).map((part, i) =>
|
|
13
|
+
urlRegex.test(part) ? (
|
|
14
|
+
<a
|
|
15
|
+
key={i}
|
|
16
|
+
href={part.trim()}
|
|
17
|
+
style={{ color: colorCode }}
|
|
18
|
+
target="_blank"
|
|
19
|
+
rel="noopener noreferrer"
|
|
20
|
+
className="underline break-words"
|
|
21
|
+
>
|
|
22
|
+
{part}
|
|
23
|
+
</a>
|
|
24
|
+
) : (
|
|
25
|
+
<span key={i}>{part}</span>
|
|
26
|
+
)
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
const triggerPhrase = 'Add this to cart for me';
|
|
32
|
+
|
|
33
|
+
// If it matches the add-to-cart pattern, show confirmation
|
|
34
|
+
if (
|
|
35
|
+
message?.startsWith(triggerPhrase) &&
|
|
36
|
+
message?.includes('name:')
|
|
37
|
+
) {
|
|
38
|
+
const nameMatch = message?.match(/name:([^,]+)/i);
|
|
39
|
+
const productName = nameMatch?.[1]?.trim() || 'Product';
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
style={{
|
|
44
|
+
backgroundColor: '#E8F5E9',
|
|
45
|
+
border: '1px solid #C8E6C9',
|
|
46
|
+
borderRadius: '12px',
|
|
47
|
+
padding: '12px 16px',
|
|
48
|
+
color: '#2E7D32',
|
|
49
|
+
fontWeight: 500,
|
|
50
|
+
fontSize: '15px',
|
|
51
|
+
maxWidth: '360px',
|
|
52
|
+
margin: '0 auto',
|
|
53
|
+
textAlign: 'center',
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
✅ <strong>{productName}</strong> has been added to your cart.
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Otherwise, render the default message with link formatting
|
|
62
|
+
return (
|
|
63
|
+
<p>
|
|
64
|
+
{formatTextWithLinks(message)}
|
|
65
|
+
</p>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default AddToCartConfirmation;
|
|
70
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// components/Messages/AgentStatusBanner.tsx
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
interface AgentStatusBannerProps {
|
|
5
|
+
status?: string;
|
|
6
|
+
user?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const AgentStatusBanner: React.FC<AgentStatusBannerProps> = ({ status, user }) => {
|
|
10
|
+
if (!status || !user) return null;
|
|
11
|
+
|
|
12
|
+
let displayText = `${user} is ${status}`;
|
|
13
|
+
|
|
14
|
+
// Override text for specific status (optional)
|
|
15
|
+
if (status === 'typing') {
|
|
16
|
+
displayText = `${user} is typing...`;
|
|
17
|
+
}
|
|
18
|
+
else{
|
|
19
|
+
displayText= ''
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
{displayText&&(
|
|
25
|
+
<div className="flex justify-center mb-2">
|
|
26
|
+
<span className="px-3 py-1 bg-gray-100 text-gray-500 text-sm rounded-full">
|
|
27
|
+
{displayText}
|
|
28
|
+
</span>
|
|
29
|
+
</div>
|
|
30
|
+
)}
|
|
31
|
+
|
|
32
|
+
</>
|
|
33
|
+
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default AgentStatusBanner;
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import React, { useState, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
interface ProductVariant {
|
|
4
|
+
name: string;
|
|
5
|
+
price: string;
|
|
6
|
+
link: string;
|
|
7
|
+
image: string;
|
|
8
|
+
variant_id: string;
|
|
9
|
+
variant_tag_name: string;
|
|
10
|
+
color?: string;
|
|
11
|
+
size?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface AssistantMessageProps {
|
|
15
|
+
message: string;
|
|
16
|
+
colorCode: string;
|
|
17
|
+
isBusiness?: boolean;
|
|
18
|
+
handleAddToCart: (variantId: string, name: string, qty?: number) => Promise<void>;
|
|
19
|
+
handleRemoveFromCart: (variantId: string, qty?: number) => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
|
23
|
+
message,
|
|
24
|
+
colorCode,
|
|
25
|
+
handleAddToCart,
|
|
26
|
+
handleRemoveFromCart
|
|
27
|
+
}) => {
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
const [counts, setCounts] = useState<Record<string, number>>({});
|
|
32
|
+
const [selectedVariants, setSelectedVariants] = useState<Record<string, string>>({});
|
|
33
|
+
|
|
34
|
+
const formatTextWithLinks = (text: string): React.ReactNode[] => {
|
|
35
|
+
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
|
36
|
+
return text?.split(urlRegex).map((part, i) =>
|
|
37
|
+
urlRegex.test(part) ? (
|
|
38
|
+
<a
|
|
39
|
+
key={i}
|
|
40
|
+
href={part.trim()}
|
|
41
|
+
style={{ color: colorCode }}
|
|
42
|
+
target="_blank"
|
|
43
|
+
rel="noopener noreferrer"
|
|
44
|
+
className="underline break-words"
|
|
45
|
+
>
|
|
46
|
+
{part}
|
|
47
|
+
</a>
|
|
48
|
+
) : (
|
|
49
|
+
<span key={i}>{part}</span>
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
const extractProductDetails = (text: string): ProductVariant | null => {
|
|
54
|
+
const normalized = text.trim();
|
|
55
|
+
|
|
56
|
+
// 🛠️ Instead of requiring it to START with 'Name:', just check if it CONTAINS it
|
|
57
|
+
if (!normalized.includes('Name:') || !normalized.includes('VariantId')) return null;
|
|
58
|
+
|
|
59
|
+
const nameMatch = normalized.match(/Name\s*[:\-]\s*(.+)/i);
|
|
60
|
+
const priceMatch = normalized.match(/Price\s*[:\-]\s*(.+)/i);
|
|
61
|
+
const linkMatch = normalized.match(/Link\s*[:\-]\s*(https?:\/\/[^\s]+)/i);
|
|
62
|
+
const variantMatch = normalized.match(/VariantId\s*[:\-]\s*(.+)/i);
|
|
63
|
+
const tagNameMatch = normalized.match(/variant_tag_name\s*[:\-]\s*(.+)/i);
|
|
64
|
+
const colorMatch = normalized.match(/^color\s*[:\-]\s*([^\r\n]*)$/im);
|
|
65
|
+
const sizeMatch = normalized.match(/^size\s*[:\-]\s*([^\r\n]*)$/im);
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if (!nameMatch || !priceMatch || !linkMatch || !variantMatch || !tagNameMatch) return null;
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
name: nameMatch[1].trim(),
|
|
80
|
+
price: priceMatch[1].trim(),
|
|
81
|
+
link: linkMatch[1].trim(),
|
|
82
|
+
image: '',
|
|
83
|
+
variant_id: variantMatch[1].trim(),
|
|
84
|
+
variant_tag_name: tagNameMatch[1].trim(),
|
|
85
|
+
color: colorMatch?.[1]?.trim(),
|
|
86
|
+
size: sizeMatch?.[1]?.trim()
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
const parsedSegments = useMemo(() => {
|
|
92
|
+
const parts = message.split('|||').map(p => p.trim()).filter(Boolean);
|
|
93
|
+
const segments: { type: 'text' | 'product'; data: any }[] = [];
|
|
94
|
+
let lastProduct: ProductVariant | null = null;
|
|
95
|
+
|
|
96
|
+
for (const part of parts) {
|
|
97
|
+
if (part.startsWith('img - ')) {
|
|
98
|
+
const url = part.replace(/^img\s*[-:]\s*/, '').trim();
|
|
99
|
+
|
|
100
|
+
// Attach to last known product
|
|
101
|
+
if (lastProduct) {
|
|
102
|
+
lastProduct.image = url;
|
|
103
|
+
} else {
|
|
104
|
+
// Fallback: attach to last product in segments if available
|
|
105
|
+
const lastSegment = [...segments].reverse().find(s => s.type === 'product');
|
|
106
|
+
if (lastSegment) {
|
|
107
|
+
(lastSegment.data as ProductVariant).image = url;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 🛠 Loosen this check to allow any block with Name: and VariantId to be considered a product
|
|
113
|
+
else if (part.includes('Name:') && part.includes('VariantId')) {
|
|
114
|
+
const product = extractProductDetails(part);
|
|
115
|
+
|
|
116
|
+
if (product) {
|
|
117
|
+
segments.push({ type: 'product', data: product });
|
|
118
|
+
lastProduct = product;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
else {
|
|
123
|
+
segments.push({ type: 'text', data: part });
|
|
124
|
+
lastProduct = null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
return segments;
|
|
130
|
+
}, [message]);
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
const groupedProducts = useMemo(() => {
|
|
134
|
+
const groups: Record<string, ProductVariant[]> = {};
|
|
135
|
+
for (const seg of parsedSegments) {
|
|
136
|
+
if (seg.type === 'product') {
|
|
137
|
+
const p = seg.data as ProductVariant;
|
|
138
|
+
if (!groups[p.variant_tag_name]) groups[p.variant_tag_name] = [];
|
|
139
|
+
groups[p.variant_tag_name].push(p);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return groups;
|
|
143
|
+
}, [parsedSegments]);
|
|
144
|
+
|
|
145
|
+
const shortenName = (name: string, maxLength = 22) =>
|
|
146
|
+
name.length > maxLength ? name.slice(0, maxLength) + '...' : name;
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className="space-y-6 mt-4">
|
|
150
|
+
{(() => {
|
|
151
|
+
const renderedTags = new Set<string>();
|
|
152
|
+
|
|
153
|
+
return parsedSegments.map((seg, idx) => {
|
|
154
|
+
if (seg.type === 'text') {
|
|
155
|
+
return (
|
|
156
|
+
<p key={idx} className="whitespace-pre-wrap break-words">
|
|
157
|
+
|
|
158
|
+
{formatTextWithLinks(seg.data)}
|
|
159
|
+
</p>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (seg.type === 'product') {
|
|
164
|
+
const product = seg.data as ProductVariant;
|
|
165
|
+
const tag = product.variant_tag_name;
|
|
166
|
+
if (renderedTags.has(tag)) return null;
|
|
167
|
+
renderedTags.add(tag);
|
|
168
|
+
|
|
169
|
+
const group = groupedProducts[tag];
|
|
170
|
+
|
|
171
|
+
const currentVariantId = selectedVariants[tag] || group[0].variant_id;
|
|
172
|
+
const currentVariant = group.find(v => v.variant_id === currentVariantId) || group[0];
|
|
173
|
+
const count = counts[currentVariant.variant_id] || 0;
|
|
174
|
+
|
|
175
|
+
const colors = Array.from(
|
|
176
|
+
new Set(group.map(v => v.color?.trim()).filter(c => c && c.toLowerCase() !== 'none'))
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const sizes = Array.from(
|
|
180
|
+
new Set(group.map(v => v.size?.trim()).filter(s => s && s.toLowerCase() !== 'none'))
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
const isComboValid = (color?: string, size?: string) =>
|
|
188
|
+
group.some(v => v.color === color && v.size === size);
|
|
189
|
+
|
|
190
|
+
const setActiveVariant = (color?: string, size?: string) => {
|
|
191
|
+
const match = group.find(v => {
|
|
192
|
+
return (!color || v.color === color) && (!size || v.size === size);
|
|
193
|
+
});
|
|
194
|
+
if (match) {
|
|
195
|
+
setSelectedVariants(prev => ({
|
|
196
|
+
...prev,
|
|
197
|
+
[tag]: match.variant_id
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const increment = async () => {
|
|
203
|
+
const next = count + 1;
|
|
204
|
+
setCounts(c => ({ ...c, [currentVariant.variant_id]: next }));
|
|
205
|
+
await handleAddToCart(currentVariant.variant_id, currentVariant.name, 1);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const decrement = async () => {
|
|
209
|
+
if (count === 0) return;
|
|
210
|
+
const next = count - 1;
|
|
211
|
+
setCounts(c => ({ ...c, [currentVariant.variant_id]: next }));
|
|
212
|
+
await handleRemoveFromCart(currentVariant.variant_id, 1);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<div
|
|
217
|
+
key={idx}
|
|
218
|
+
className="bg-white shadow-md rounded-2xl p-4 flex flex-col sm:flex-row items-center sm:items-start space-y-4 sm:space-y-0 sm:space-x-6"
|
|
219
|
+
>
|
|
220
|
+
<div className="w-32 h-32 sm:w-40 sm:h-40">
|
|
221
|
+
<img
|
|
222
|
+
src={currentVariant.image}
|
|
223
|
+
alt={currentVariant.name}
|
|
224
|
+
className="w-full h-full object-cover rounded-xl cursor-pointer"
|
|
225
|
+
onClick={() => window.open(currentVariant.link, '_blank')}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<div className="flex-1 space-y-3">
|
|
230
|
+
<a
|
|
231
|
+
href={currentVariant.link}
|
|
232
|
+
target="_blank"
|
|
233
|
+
rel="noopener noreferrer"
|
|
234
|
+
className="text-lg font-semibold text-gray-900"
|
|
235
|
+
>
|
|
236
|
+
{shortenName(currentVariant.name)}
|
|
237
|
+
</a>
|
|
238
|
+
<p className="text-md font-medium text-gray-700">{currentVariant.price}</p>
|
|
239
|
+
|
|
240
|
+
{colors.length > 0 && (
|
|
241
|
+
<div>
|
|
242
|
+
<p className="text-sm text-gray-500 font-medium">Color:</p>
|
|
243
|
+
<div className="flex flex-wrap gap-2 mt-1">
|
|
244
|
+
{colors.map(color => {
|
|
245
|
+
const disabled = !isComboValid(color, currentVariant.size);
|
|
246
|
+
const isActive = color === currentVariant.color;
|
|
247
|
+
return (
|
|
248
|
+
<button
|
|
249
|
+
key={color}
|
|
250
|
+
disabled={disabled}
|
|
251
|
+
onClick={() => setActiveVariant(color, currentVariant.size)}
|
|
252
|
+
className={`px-3 py-1 rounded-full border text-sm ${
|
|
253
|
+
isActive ? 'text-white' : 'text-gray-800'
|
|
254
|
+
} ${disabled ? 'opacity-40 cursor-not-allowed' : 'bg-gray-100'}`}
|
|
255
|
+
style={isActive ? { backgroundColor: colorCode,color:'white' } : {}}
|
|
256
|
+
>
|
|
257
|
+
{color=="None"?"One Color" :color}
|
|
258
|
+
</button>
|
|
259
|
+
);
|
|
260
|
+
})}
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
264
|
+
|
|
265
|
+
{sizes.length > 0 && (
|
|
266
|
+
<div>
|
|
267
|
+
<p className="text-sm text-gray-500 font-medium">Size:</p>
|
|
268
|
+
<div className="flex flex-wrap gap-2 mt-1">
|
|
269
|
+
{sizes.map(size => {
|
|
270
|
+
const disabled = !isComboValid(currentVariant.color, size);
|
|
271
|
+
const isActive = size === currentVariant.size;
|
|
272
|
+
return (
|
|
273
|
+
<button
|
|
274
|
+
key={size}
|
|
275
|
+
disabled={disabled}
|
|
276
|
+
onClick={() => setActiveVariant(currentVariant.color, size)}
|
|
277
|
+
className={`px-3 py-1 rounded-full border text-sm ${
|
|
278
|
+
isActive ? 'text-white' : 'text-gray-800'
|
|
279
|
+
} ${disabled ? 'opacity-40 cursor-not-allowed' : 'bg-gray-100'}`}
|
|
280
|
+
style={isActive ? { backgroundColor: colorCode,color:'white' } : {}}
|
|
281
|
+
>
|
|
282
|
+
{size==="None"? "One size":size}
|
|
283
|
+
</button>
|
|
284
|
+
);
|
|
285
|
+
})}
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{/* Cart Controls */}
|
|
291
|
+
<div className="inline-flex items-center space-x-3 mt-2">
|
|
292
|
+
<button onClick={decrement} className="px-3 py-1 rounded-lg border">
|
|
293
|
+
–
|
|
294
|
+
</button>
|
|
295
|
+
<span className="min-w-[1.25rem] text-center font-medium">{count}</span>
|
|
296
|
+
<button
|
|
297
|
+
onClick={increment}
|
|
298
|
+
className="px-3 py-1 rounded-lg border"
|
|
299
|
+
style={{ backgroundColor: colorCode, color: '#fff' }}
|
|
300
|
+
>
|
|
301
|
+
+
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return null;
|
|
310
|
+
});
|
|
311
|
+
})()}
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
export default AssistantMessage;
|