shopify-chatbot-widget 0.7.8 → 0.8.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/jaweb-chatbot.css +1 -1
- package/dist/jaweb-chatbot.iife.js +117 -117
- package/package.json +3 -1
- package/src/App.css +42 -0
- package/src/App.tsx +13 -0
- package/src/Chatbot/Chatbot.css +270 -0
- package/src/Chatbot/Chatbot.tsx +214 -0
- package/src/Chatbot/ChatbotTransition.css +19 -0
- package/src/Chatbot/OLDCHAT.tsx +259 -0
- package/src/Chatbot/components/ChatInput.css +127 -0
- package/src/Chatbot/components/ChatInput.tsx +354 -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 +206 -0
- package/src/Chatbot/components/Messages/AudioMessage.tsx +123 -0
- package/src/Chatbot/components/Messages/CalendlyEmbeds.tsx +66 -0
- package/src/Chatbot/components/Messages/CardSwiper.tsx +194 -0
- package/src/Chatbot/components/Messages/Carousel/CardSwiper.tsx +264 -0
- package/src/Chatbot/components/Messages/Carousel/CardSwiperSalla.tsx +208 -0
- package/src/Chatbot/components/Messages/Carousel/CardSwiperZid.tsx +208 -0
- package/src/Chatbot/components/Messages/Carousel/SallaSwiper.css +245 -0
- package/src/Chatbot/components/Messages/Carousel/TryOn.tsx +108 -0
- package/src/Chatbot/components/Messages/CartWidget.tsx +151 -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 +31 -0
- package/src/Chatbot/components/Messages/ChatMessages.tsx +334 -0
- package/src/Chatbot/components/Messages/ProductCard.css +149 -0
- package/src/Chatbot/components/Messages/SallaAssistantMessage.tsx +196 -0
- package/src/Chatbot/components/Messages/Support.tsx +177 -0
- package/src/Chatbot/components/Messages/Typing.css +31 -0
- package/src/Chatbot/components/Messages/Typing.tsx +19 -0
- package/src/Chatbot/components/Messages/ZidAssistantMesage.tsx +196 -0
- package/src/Chatbot/components/Messages/cartwidget.css +123 -0
- package/src/Chatbot/components/Messenger/Messenger.tsx +531 -0
- package/src/Chatbot/components/Notification.tsx +28 -0
- package/src/Chatbot/components/Powered.css +29 -0
- package/src/Chatbot/components/Powered.tsx +12 -0
- package/src/Chatbot/components/Sessions/CreateSession.tsx +201 -0
- package/src/Chatbot/components/Sessions/RenderList.tsx +212 -0
- package/src/Chatbot/components/Suggestions.css +35 -0
- package/src/Chatbot/components/Suggestions.tsx +57 -0
- package/src/Chatbot/notification.css +17 -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 +107 -0
- package/src/hooks/useChatbotAPI.tsx +167 -0
- package/src/hooks/useChatbotData.tsx +132 -0
- package/src/hooks/useSessionMessages.tsx +50 -0
- package/src/hooks/useWebsocke.tsx +45 -0
- package/src/i18n.tsx +30 -0
- package/src/index.css +2 -0
- package/src/main.tsx +10 -0
- package/src/types/chatbot.ts +40 -0
- package/src/utils/cookies.tsx +14 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import WaveSurfer from "wavesurfer.js";
|
|
3
|
+
import {
|
|
4
|
+
PlayCircleOutlined,
|
|
5
|
+
PauseCircleOutlined
|
|
6
|
+
} from "@ant-design/icons";
|
|
7
|
+
|
|
8
|
+
interface AudioVisualizerProps {
|
|
9
|
+
link: string;
|
|
10
|
+
colorCode: string;
|
|
11
|
+
transcription?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const AudioVisualizer: React.FC<AudioVisualizerProps> = ({
|
|
15
|
+
link,
|
|
16
|
+
colorCode,
|
|
17
|
+
transcription,
|
|
18
|
+
}) => {
|
|
19
|
+
const audioRef = useRef<HTMLDivElement | null>(null);
|
|
20
|
+
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
|
21
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
22
|
+
const [showTranscription, setShowTranscription] = useState(false);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!audioRef.current) return;
|
|
26
|
+
|
|
27
|
+
wavesurferRef.current?.destroy();
|
|
28
|
+
|
|
29
|
+
wavesurferRef.current = WaveSurfer.create({
|
|
30
|
+
container: audioRef.current,
|
|
31
|
+
waveColor: "white",
|
|
32
|
+
progressColor: "white",
|
|
33
|
+
cursorWidth: 0,
|
|
34
|
+
barWidth: 3,
|
|
35
|
+
barHeight: 1.5,
|
|
36
|
+
barGap: 2,
|
|
37
|
+
height: 40,
|
|
38
|
+
interact: true,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
wavesurferRef.current.load(link);
|
|
42
|
+
wavesurferRef.current.on("finish", () => setIsPlaying(false));
|
|
43
|
+
|
|
44
|
+
return () => {
|
|
45
|
+
wavesurferRef.current?.destroy();
|
|
46
|
+
};
|
|
47
|
+
}, [link, colorCode]);
|
|
48
|
+
|
|
49
|
+
const togglePlay = () => {
|
|
50
|
+
if (wavesurferRef.current) {
|
|
51
|
+
wavesurferRef.current.playPause();
|
|
52
|
+
setIsPlaying((prev) => !prev);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
style={{
|
|
59
|
+
backgroundColor:colorCode,
|
|
60
|
+
padding: "12px",
|
|
61
|
+
borderRadius: "20px",
|
|
62
|
+
color: "white",
|
|
63
|
+
maxWidth: "350px",
|
|
64
|
+
marginBottom: "16px",
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<div style={{ display: "flex", alignItems: "center" }}>
|
|
68
|
+
<button
|
|
69
|
+
onClick={togglePlay}
|
|
70
|
+
style={{
|
|
71
|
+
background: "transparent",
|
|
72
|
+
border: "none",
|
|
73
|
+
color: "white",
|
|
74
|
+
marginRight: "12px",
|
|
75
|
+
cursor: "pointer",
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{isPlaying ? (
|
|
79
|
+
<PauseCircleOutlined style={{ fontSize: "24px" }} />
|
|
80
|
+
) : (
|
|
81
|
+
<PlayCircleOutlined style={{ fontSize: "24px" }} />
|
|
82
|
+
)}
|
|
83
|
+
</button>
|
|
84
|
+
|
|
85
|
+
<div
|
|
86
|
+
ref={audioRef}
|
|
87
|
+
style={{
|
|
88
|
+
width: "130px",
|
|
89
|
+
height: "40px",
|
|
90
|
+
overflow: "hidden",
|
|
91
|
+
color:'white'
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
94
|
+
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{transcription && (
|
|
98
|
+
<div style={{ marginTop: "12px", fontSize: "14px", color: "rgba(255,255,255,0.85)" }}>
|
|
99
|
+
<button
|
|
100
|
+
onClick={() => setShowTranscription(!showTranscription)}
|
|
101
|
+
style={{
|
|
102
|
+
background: "none",
|
|
103
|
+
border: "none",
|
|
104
|
+
color: "white",
|
|
105
|
+
textDecoration: "underline",
|
|
106
|
+
fontSize: "13px",
|
|
107
|
+
cursor: "pointer",
|
|
108
|
+
padding: 0,
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{showTranscription ? "Hide transcript" : "Show transcript"}
|
|
112
|
+
</button>
|
|
113
|
+
|
|
114
|
+
{showTranscription && (
|
|
115
|
+
<p style={{ marginTop: "6px", lineHeight: "1.4" }}>{transcription}</p>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export default AudioVisualizer;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface CalendlyEmbedsProps {
|
|
4
|
+
calendlyLink: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
declare global {
|
|
8
|
+
interface Window {
|
|
9
|
+
Calendly?: {
|
|
10
|
+
initInlineWidget: (options: {
|
|
11
|
+
url: string;
|
|
12
|
+
parentElement: HTMLElement;
|
|
13
|
+
prefill?: Record<string, unknown>;
|
|
14
|
+
utm?: Record<string, unknown>;
|
|
15
|
+
}) => void;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function CalendlyEmbeds({ calendlyLink }: CalendlyEmbedsProps) {
|
|
21
|
+
const calendlyRef = useRef<HTMLDivElement | null>(null); // Typed ref
|
|
22
|
+
const [scriptLoaded, setScriptLoaded] = useState(false);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
// Only load the script if it's not already loaded
|
|
26
|
+
if (!window.Calendly) {
|
|
27
|
+
const script = document.createElement("script");
|
|
28
|
+
script.src = "https://assets.calendly.com/assets/external/widget.js";
|
|
29
|
+
script.async = true;
|
|
30
|
+
script.onload = () => setScriptLoaded(true);
|
|
31
|
+
document.body.appendChild(script);
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
document.body.removeChild(script);
|
|
35
|
+
};
|
|
36
|
+
} else {
|
|
37
|
+
setScriptLoaded(true); // Calendly already loaded
|
|
38
|
+
}
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (scriptLoaded && calendlyLink && calendlyRef.current && window.Calendly) {
|
|
43
|
+
// Initialize Calendly widget once the script is loaded and the ref container is available
|
|
44
|
+
window.Calendly.initInlineWidget({
|
|
45
|
+
url: calendlyLink,
|
|
46
|
+
parentElement: calendlyRef.current,
|
|
47
|
+
prefill: {},
|
|
48
|
+
utm: {},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}, [scriptLoaded, calendlyLink]);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
ref={calendlyRef}
|
|
56
|
+
style={{
|
|
57
|
+
minWidth: '320px',
|
|
58
|
+
height: '350px',
|
|
59
|
+
borderRadius: '8px',
|
|
60
|
+
boxShadow: '0 0 10px rgba(0, 0, 0, 0.1)',
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
{/* Calendly widget will be injected here */}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { FC } from 'react';
|
|
3
|
+
import 'keen-slider/keen-slider.min.css';
|
|
4
|
+
import type { KeenSliderInstance } from 'keen-slider/react';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
interface ProductVariant {
|
|
8
|
+
variant_id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
image: string;
|
|
11
|
+
link: string;
|
|
12
|
+
price: string;
|
|
13
|
+
color?: string;
|
|
14
|
+
size?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
groupedProducts: Record<string, ProductVariant[]>;
|
|
19
|
+
selectedVariants: Record<string, string>;
|
|
20
|
+
setSelectedVariants: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
|
21
|
+
counts: Record<string, number>;
|
|
22
|
+
setCounts: React.Dispatch<React.SetStateAction<Record<string, number>>>;
|
|
23
|
+
handleAddToCart: (variantId: string, name: string, qty: number) => Promise<void>;
|
|
24
|
+
handleRemoveFromCart: (variantId: string, qty: number) => Promise<void>;
|
|
25
|
+
colorCode?: string;
|
|
26
|
+
shortenName: (name: string) => string;
|
|
27
|
+
|
|
28
|
+
sliderRef: React.Ref<HTMLDivElement>;
|
|
29
|
+
sliderInstance: React.MutableRefObject<KeenSliderInstance | null>;
|
|
30
|
+
currentSlide: number;
|
|
31
|
+
totalSlides: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ProductCarousel: FC<Props> = ({
|
|
35
|
+
groupedProducts,
|
|
36
|
+
selectedVariants,
|
|
37
|
+
setSelectedVariants,
|
|
38
|
+
counts,
|
|
39
|
+
setCounts,
|
|
40
|
+
handleAddToCart,
|
|
41
|
+
handleRemoveFromCart,
|
|
42
|
+
colorCode = '#7F28F8',
|
|
43
|
+
shortenName,
|
|
44
|
+
sliderRef,
|
|
45
|
+
totalSlides,
|
|
46
|
+
}) => {
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if (totalSlides === 0) return null;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="relative w-full">
|
|
53
|
+
{/* Slider container with padding for arrow overlay */}
|
|
54
|
+
<div ref={sliderRef} className="keen-slider relative px-8">
|
|
55
|
+
{Object.entries(groupedProducts).map(([tag, group]) => {
|
|
56
|
+
const currentVariantId = selectedVariants[tag] || group[0].variant_id;
|
|
57
|
+
const currentVariant = group.find(v => v.variant_id === currentVariantId) || group[0];
|
|
58
|
+
const count = counts[currentVariant.variant_id] || 0;
|
|
59
|
+
|
|
60
|
+
const colors = Array.from(
|
|
61
|
+
new Set(
|
|
62
|
+
group.map(v => v.color?.trim() || '').filter(c => c && c.toLowerCase() !== 'none')
|
|
63
|
+
)
|
|
64
|
+
);
|
|
65
|
+
const sizes = Array.from(
|
|
66
|
+
new Set(
|
|
67
|
+
group.map(v => v.size?.trim() || '').filter(s => s && s.toLowerCase() !== 'none')
|
|
68
|
+
)
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const isComboValid = (color?: string, size?: string) =>
|
|
72
|
+
group.some(v => v.color === color && v.size === size);
|
|
73
|
+
|
|
74
|
+
const setActiveVariant = (color?: string, size?: string) => {
|
|
75
|
+
const match = group.find(
|
|
76
|
+
v => (!color || v.color === color) && (!size || v.size === size)
|
|
77
|
+
);
|
|
78
|
+
if (match) {
|
|
79
|
+
setSelectedVariants(prev => ({
|
|
80
|
+
...prev,
|
|
81
|
+
[tag]: match.variant_id,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const increment = async () => {
|
|
87
|
+
setCounts(prev => ({
|
|
88
|
+
...prev,
|
|
89
|
+
[currentVariant.variant_id]: (prev[currentVariant.variant_id] || 0) + 1,
|
|
90
|
+
}));
|
|
91
|
+
await handleAddToCart(currentVariant.variant_id, currentVariant.name, 1);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const decrement = async () => {
|
|
95
|
+
if (count === 0) return;
|
|
96
|
+
setCounts(prev => ({
|
|
97
|
+
...prev,
|
|
98
|
+
[currentVariant.variant_id]: prev[currentVariant.variant_id] - 1,
|
|
99
|
+
}));
|
|
100
|
+
await handleRemoveFromCart(currentVariant.variant_id, 1);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div key={tag} className="keen-slider__slide">
|
|
105
|
+
<div className="product-card">
|
|
106
|
+
<div className="product-image-container">
|
|
107
|
+
<img
|
|
108
|
+
src={currentVariant.image}
|
|
109
|
+
alt={currentVariant.name}
|
|
110
|
+
className="product-image"
|
|
111
|
+
onClick={() => window.open(currentVariant.link, '_blank')}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
<div className="product-details">
|
|
115
|
+
<a
|
|
116
|
+
href={currentVariant.link}
|
|
117
|
+
target="_blank"
|
|
118
|
+
rel="noopener noreferrer"
|
|
119
|
+
className="product-name"
|
|
120
|
+
>
|
|
121
|
+
{shortenName(currentVariant.name)}
|
|
122
|
+
</a>
|
|
123
|
+
<p className="product-price">{currentVariant.price}</p>
|
|
124
|
+
|
|
125
|
+
{colors.length > 0 && (
|
|
126
|
+
<div>
|
|
127
|
+
<p className="label">Color:</p>
|
|
128
|
+
<div className="color-options">
|
|
129
|
+
{colors.map(color => {
|
|
130
|
+
const disabled = !isComboValid(color, currentVariant.size);
|
|
131
|
+
const isActive = color === currentVariant.color;
|
|
132
|
+
return (
|
|
133
|
+
<button
|
|
134
|
+
key={color}
|
|
135
|
+
disabled={disabled}
|
|
136
|
+
onClick={() => setActiveVariant(color, currentVariant.size)}
|
|
137
|
+
className={`color-button ${isActive ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
|
|
138
|
+
style={isActive ? { backgroundColor: colorCode, color: '#fff' } : {}}
|
|
139
|
+
>
|
|
140
|
+
{color === 'None' ? 'One Color' : color}
|
|
141
|
+
</button>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{sizes.length > 0 && (
|
|
149
|
+
<div>
|
|
150
|
+
<p className="label">Size:</p>
|
|
151
|
+
<div className="size-options">
|
|
152
|
+
{sizes.map(size => {
|
|
153
|
+
const disabled = !isComboValid(currentVariant.color, size);
|
|
154
|
+
const isActive = size === currentVariant.size;
|
|
155
|
+
return (
|
|
156
|
+
<button
|
|
157
|
+
key={size}
|
|
158
|
+
disabled={disabled}
|
|
159
|
+
onClick={() => setActiveVariant(currentVariant.color, size)}
|
|
160
|
+
className={`size-button ${isActive ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
|
|
161
|
+
style={isActive ? { backgroundColor: colorCode, color: '#fff' } : {}}
|
|
162
|
+
>
|
|
163
|
+
{size === 'None' ? 'One size' : size}
|
|
164
|
+
</button>
|
|
165
|
+
);
|
|
166
|
+
})}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
<div className="cart-controls">
|
|
172
|
+
<button onClick={decrement} className="cart-button">–</button>
|
|
173
|
+
<span className="cart-count">{count}</span>
|
|
174
|
+
<button
|
|
175
|
+
onClick={increment}
|
|
176
|
+
className="cart-button increment"
|
|
177
|
+
style={{ backgroundColor: colorCode, color: '#fff' }}
|
|
178
|
+
>
|
|
179
|
+
+
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
})}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export default ProductCarousel;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import type { FC } from "react";
|
|
3
|
+
import "keen-slider/keen-slider.min.css";
|
|
4
|
+
import type { KeenSliderInstance } from "keen-slider/react";
|
|
5
|
+
import "./SallaSwiper.css";
|
|
6
|
+
import { Button } from "antd";
|
|
7
|
+
import TryOnBox from "./TryOn";
|
|
8
|
+
|
|
9
|
+
interface ProductVariant {
|
|
10
|
+
variant_id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
image: string;
|
|
13
|
+
link: string;
|
|
14
|
+
price: string;
|
|
15
|
+
option?: string; // e.g. "Red - XL"
|
|
16
|
+
variant_tag_name: string;
|
|
17
|
+
tag?: string; // optional: "clothing" | "jewelry" | ...
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
groupedProducts: Record<string, ProductVariant[]>;
|
|
22
|
+
selectedVariants: Record<string, string>;
|
|
23
|
+
setSelectedVariants: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
|
24
|
+
counts: Record<string, number>;
|
|
25
|
+
setCounts: React.Dispatch<React.SetStateAction<Record<string, number>>>;
|
|
26
|
+
handleAddToCart: (variantId: string, name: string, qty?: number) => Promise<void>;
|
|
27
|
+
handleRemoveFromCart: (variantId: string, qty?: number) => Promise<void>;
|
|
28
|
+
colorCode?: string;
|
|
29
|
+
shortenName: (name: string) => string;
|
|
30
|
+
disableCheckout?: boolean;
|
|
31
|
+
|
|
32
|
+
// slider
|
|
33
|
+
sliderRef: React.Ref<HTMLDivElement>;
|
|
34
|
+
sliderInstance: React.MutableRefObject<KeenSliderInstance | null>;
|
|
35
|
+
currentSlide: number;
|
|
36
|
+
totalSlides: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ProductCarousel: FC<Props> = ({
|
|
40
|
+
groupedProducts,
|
|
41
|
+
selectedVariants,
|
|
42
|
+
setSelectedVariants,
|
|
43
|
+
counts,
|
|
44
|
+
setCounts,
|
|
45
|
+
handleAddToCart,
|
|
46
|
+
handleRemoveFromCart,
|
|
47
|
+
colorCode = "#7F28F8",
|
|
48
|
+
shortenName,
|
|
49
|
+
sliderRef,
|
|
50
|
+
totalSlides,
|
|
51
|
+
disableCheckout = false,
|
|
52
|
+
}) => {
|
|
53
|
+
if (!totalSlides || totalSlides === 0) return null;
|
|
54
|
+
|
|
55
|
+
// track which variant is open for Try On
|
|
56
|
+
const [openTryOn, setOpenTryOn] = useState<string | null>(null);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<section
|
|
60
|
+
className="sallaX"
|
|
61
|
+
style={{ "--sallaX-accent": colorCode } as React.CSSProperties}
|
|
62
|
+
aria-label="Recommended products"
|
|
63
|
+
>
|
|
64
|
+
<div ref={sliderRef} className="keen-slider sallaX-slider">
|
|
65
|
+
{Object.entries(groupedProducts).map(([tag, group]) => {
|
|
66
|
+
// figure out current active variant within this group
|
|
67
|
+
const currentVariantId =
|
|
68
|
+
selectedVariants[tag] || (group.length > 0 ? group[0].variant_id : "");
|
|
69
|
+
const currentVariant =
|
|
70
|
+
group.find(v => v.variant_id === currentVariantId) || group[0];
|
|
71
|
+
if (!currentVariant) return null;
|
|
72
|
+
|
|
73
|
+
const count = counts[currentVariant.variant_id] || 0;
|
|
74
|
+
|
|
75
|
+
// Build unique, cleaned list of options (exclude empty/'none')
|
|
76
|
+
const options = Array.from(
|
|
77
|
+
new Set(
|
|
78
|
+
group
|
|
79
|
+
.map(v => (v.option?.trim() ?? ""))
|
|
80
|
+
.filter(o => o && o.toLowerCase() !== "none")
|
|
81
|
+
)
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Update active variant by chosen option
|
|
85
|
+
const setActiveVariant = (option?: string) => {
|
|
86
|
+
const match = group.find(v => v.option === option);
|
|
87
|
+
if (match) {
|
|
88
|
+
setSelectedVariants(prev => ({
|
|
89
|
+
...prev,
|
|
90
|
+
[tag]: match.variant_id,
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Ensure select has a stable value (fallback to first option)
|
|
96
|
+
const selectValue = currentVariant.option ?? options[0] ?? "";
|
|
97
|
+
|
|
98
|
+
const increment = async () => {
|
|
99
|
+
setCounts(prev => ({
|
|
100
|
+
...prev,
|
|
101
|
+
[currentVariant.variant_id]: (prev[currentVariant.variant_id] || 0) + 1,
|
|
102
|
+
}));
|
|
103
|
+
try {
|
|
104
|
+
// wait for backend to confirm success
|
|
105
|
+
await handleAddToCart(currentVariant.variant_id, currentVariant.name, 1);
|
|
106
|
+
|
|
107
|
+
// only update counts if the request succeeded
|
|
108
|
+
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
console.warn("Add to cart failed:", err.message);
|
|
111
|
+
setCounts(prev => ({
|
|
112
|
+
...prev,
|
|
113
|
+
[currentVariant.variant_id]: (prev[currentVariant.variant_id] || 0) - 1,
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
const decrement = async () => {
|
|
120
|
+
if (count === 0) return;
|
|
121
|
+
setCounts(prev => ({
|
|
122
|
+
...prev,
|
|
123
|
+
[currentVariant.variant_id]: Math.max(
|
|
124
|
+
0,
|
|
125
|
+
(prev[currentVariant.variant_id] || 0) - 1
|
|
126
|
+
),
|
|
127
|
+
}));
|
|
128
|
+
await handleRemoveFromCart(currentVariant.variant_id, 1);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const isTryOnOpen = openTryOn === currentVariant.variant_id;
|
|
132
|
+
const canTryOn =
|
|
133
|
+
currentVariant.tag
|
|
134
|
+
? currentVariant.tag.toLowerCase() === "clothing"
|
|
135
|
+
: true; // show by default unless you want to gate strictly by tag
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<article
|
|
139
|
+
key={tag}
|
|
140
|
+
className="keen-slider__slide sallaX-card"
|
|
141
|
+
role="group"
|
|
142
|
+
aria-roledescription="slide"
|
|
143
|
+
>
|
|
144
|
+
<div className="sallaX-media" onClick={() => window.open(currentVariant.link, "_blank")}>
|
|
145
|
+
<img
|
|
146
|
+
src={currentVariant.image}
|
|
147
|
+
alt={currentVariant.name}
|
|
148
|
+
className="sallaX-img"
|
|
149
|
+
loading="lazy"
|
|
150
|
+
/>
|
|
151
|
+
<a
|
|
152
|
+
href={currentVariant.link}
|
|
153
|
+
target="_blank"
|
|
154
|
+
rel="noopener noreferrer"
|
|
155
|
+
className="sallaX-visit"
|
|
156
|
+
aria-label="Open product page"
|
|
157
|
+
onClick={(e) => e.stopPropagation()}
|
|
158
|
+
>
|
|
159
|
+
View
|
|
160
|
+
</a>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="sallaX-body">
|
|
164
|
+
<a
|
|
165
|
+
href={currentVariant.link}
|
|
166
|
+
target="_blank"
|
|
167
|
+
rel="noopener noreferrer"
|
|
168
|
+
className="sallaX-name"
|
|
169
|
+
title={currentVariant.name}
|
|
170
|
+
>
|
|
171
|
+
{shortenName(currentVariant.name)}
|
|
172
|
+
</a>
|
|
173
|
+
|
|
174
|
+
<div className="sallaX-row">
|
|
175
|
+
<span className="sallaX-price" aria-label="Price">
|
|
176
|
+
{currentVariant.price}
|
|
177
|
+
</span>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* ✅ Options dropdown restored */}
|
|
181
|
+
{options.length > 1 && (
|
|
182
|
+
<div className="sallaX-selectWrap">
|
|
183
|
+
<label
|
|
184
|
+
htmlFor={`sallaX-select-${tag}`}
|
|
185
|
+
className="sallaX-selectLabel"
|
|
186
|
+
>
|
|
187
|
+
Choose an option
|
|
188
|
+
</label>
|
|
189
|
+
<select
|
|
190
|
+
id={`sallaX-select-${tag}`}
|
|
191
|
+
className="sallaX-select"
|
|
192
|
+
aria-label="Choose an option"
|
|
193
|
+
value={selectValue}
|
|
194
|
+
onChange={(e) => setActiveVariant(e.target.value)}
|
|
195
|
+
>
|
|
196
|
+
{options.map((option) => (
|
|
197
|
+
<option key={option} value={option}>
|
|
198
|
+
{option}
|
|
199
|
+
</option>
|
|
200
|
+
))}
|
|
201
|
+
</select>
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
<div className="flex items-center justify-between w-full space-x-4">
|
|
206
|
+
{!disableCheckout && (
|
|
207
|
+
<div className="sallaX-cta" aria-label="Quantity controls">
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
className="sallaX-qty sallaX-qty--minus"
|
|
211
|
+
onClick={decrement}
|
|
212
|
+
aria-label="Decrease quantity"
|
|
213
|
+
disabled={count === 0}
|
|
214
|
+
>
|
|
215
|
+
–
|
|
216
|
+
</button>
|
|
217
|
+
<output
|
|
218
|
+
className="sallaX-qtyCount"
|
|
219
|
+
aria-live="polite"
|
|
220
|
+
aria-atomic="true"
|
|
221
|
+
>
|
|
222
|
+
{count}
|
|
223
|
+
</output>
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
className="sallaX-qty sallaX-qty--plus"
|
|
227
|
+
onClick={increment}
|
|
228
|
+
aria-label="Increase quantity"
|
|
229
|
+
>
|
|
230
|
+
+
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{/* Try On toggle button (kept) */}
|
|
236
|
+
{canTryOn && (
|
|
237
|
+
<Button
|
|
238
|
+
style={{ backgroundColor: colorCode, color: "white" }}
|
|
239
|
+
className="px-6 py-2 rounded-lg shadow-md transition"
|
|
240
|
+
onClick={() =>
|
|
241
|
+
setOpenTryOn(isTryOnOpen ? null : currentVariant.variant_id)
|
|
242
|
+
}
|
|
243
|
+
>
|
|
244
|
+
{isTryOnOpen ? "Close Try On" : "Try On"}
|
|
245
|
+
</Button>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{/* Try On panel */}
|
|
250
|
+
{canTryOn && isTryOnOpen && (
|
|
251
|
+
<div className="mt-4">
|
|
252
|
+
<TryOnBox productImage={currentVariant.image} />
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
</article>
|
|
257
|
+
);
|
|
258
|
+
})}
|
|
259
|
+
</div>
|
|
260
|
+
</section>
|
|
261
|
+
);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export default ProductCarousel;
|