Compare commits
10 Commits
c3cc8572ce
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b1345d046 | ||
|
|
c1a9f37888 | ||
|
|
7abdf68372 | ||
|
|
262dce053f | ||
|
|
6db8f11ae9 | ||
|
|
7d999a17c3 | ||
|
|
ae11a094eb | ||
|
|
1ea29e5d1e | ||
|
|
23ee6fe309 | ||
|
|
68fd4e6b33 |
BIN
public/back.png
Normal file
BIN
public/back.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
BIN
public/camera.png
Normal file
BIN
public/camera.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/face.png
Normal file
BIN
public/face.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
BIN
public/send.png
Normal file
BIN
public/send.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
BIN
public/upload.png
Normal file
BIN
public/upload.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
299
src/Camera.js
Normal file
299
src/Camera.js
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
const CameraPage = ({ handleClose, handleUploadImage }) => {
|
||||||
|
const [image, setImage] = useState(null);
|
||||||
|
const [isCameraActive, setIsCameraActive] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [isUploadedFile, setIsUploadedFile] = useState(false); // ✅ NEW STATE
|
||||||
|
const videoRef = useRef(null);
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth <= 768);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', checkMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let stream;
|
||||||
|
|
||||||
|
const startCamera = async () => {
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: 'environment' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error accessing camera:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
startCamera();
|
||||||
|
|
||||||
|
// Clean-up logic (seperti socket.off)
|
||||||
|
return () => {
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
const captureImage = () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const video = videoRef.current;
|
||||||
|
|
||||||
|
if (video && canvas) {
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const capturedImage = canvas.toDataURL('image/jpeg');
|
||||||
|
setImage(capturedImage);
|
||||||
|
setIsCameraActive(false);
|
||||||
|
setIsUploadedFile(false); // ✅ from camera
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = e => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setImage(reader.result);
|
||||||
|
setIsCameraActive(false);
|
||||||
|
setIsUploadedFile(true); // ✅ from file
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelCapture = () => {
|
||||||
|
setImage(null);
|
||||||
|
setIsCameraActive(true);
|
||||||
|
setIsUploadedFile(false); // ✅ reset
|
||||||
|
|
||||||
|
const startCamera = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: 'environment' },
|
||||||
|
});
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error accessing camera:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
startCamera();
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainContent = (
|
||||||
|
<div style={containerStyle}>
|
||||||
|
<div style={cameraContainerStyle}>
|
||||||
|
{isCameraActive && (
|
||||||
|
<video ref={videoRef} autoPlay playsInline style={videoStyle} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isCameraActive && image && (
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt="Captured or Uploaded"
|
||||||
|
style={{
|
||||||
|
...imageStyle,
|
||||||
|
objectFit: isUploadedFile ? 'contain' : 'cover',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={controlsStyle}>
|
||||||
|
{isCameraActive ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
style={baseButtonStyle}
|
||||||
|
aria-label="Cancel and retake"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/back.png"
|
||||||
|
alt="Kamera"
|
||||||
|
style={{ height: '26px' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={captureImage}
|
||||||
|
style={baseButtonStyle}
|
||||||
|
aria-label="Capture photo"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/camera.png"
|
||||||
|
alt="Kamera"
|
||||||
|
style={{ height: '24px' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={triggerFileInput}
|
||||||
|
style={baseButtonStyle}
|
||||||
|
aria-label="Upload from gallery"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/upload.png"
|
||||||
|
alt="Kamera"
|
||||||
|
style={{ height: '24px' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={cancelCapture}
|
||||||
|
style={baseButtonStyle}
|
||||||
|
aria-label="Cancel and retake"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/back.png"
|
||||||
|
alt="Kamera"
|
||||||
|
style={{ height: '26px' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUploadImage(image)}
|
||||||
|
style={baseButtonStyle}
|
||||||
|
disabled={uploading}
|
||||||
|
aria-label={uploading ? 'Uploading...' : 'Confirm upload'}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/send.png"
|
||||||
|
alt="Kamera"
|
||||||
|
style={{ height: '24px' }}
|
||||||
|
/> </button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<canvas ref={canvasRef} style={{ display: 'none' }}></canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
return (
|
||||||
|
<div style={desktopLayoutStyle}>
|
||||||
|
<div style={sidebarStyle}></div>
|
||||||
|
<div style={mainContentStyle}>{mainContent}</div>
|
||||||
|
<div style={sidebarStyle}></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mainContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const containerStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
|
||||||
|
const cameraContainerStyle = {
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
};
|
||||||
|
|
||||||
|
const videoStyle = {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageStyle = {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
const controlsStyle = {
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '30px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '20px',
|
||||||
|
alignItems: 'center',
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseButtonStyle = {
|
||||||
|
width: '60px',
|
||||||
|
height: '60px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const confirmButtonStyle = {
|
||||||
|
...baseButtonStyle,
|
||||||
|
backgroundColor: '#4CAF50',
|
||||||
|
color: '#fff',
|
||||||
|
};
|
||||||
|
|
||||||
|
const desktopLayoutStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
height: '100vh',
|
||||||
|
fontFamily: 'Arial, sans-serif',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sidebarStyle = {
|
||||||
|
width: '250px',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
color: '#333',
|
||||||
|
padding: '20px',
|
||||||
|
boxShadow: '0 0 10px rgba(0, 0, 0, 0.1)',
|
||||||
|
borderRight: '1px solid #e0e0e0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainContentStyle = {
|
||||||
|
flex: 1,
|
||||||
|
position: 'relative',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CameraPage;
|
||||||
243
src/ChatBot.js
243
src/ChatBot.js
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import styles from './ChatBot.module.css';
|
import styles from './ChatBot.module.css';
|
||||||
|
import Camera from './Camera';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const ChatBot = ({ existingConversation }) => {
|
const ChatBot = ({ existingConversation }) => {
|
||||||
const [messages, setMessages] = useState([
|
const [messages, setMessages] = useState([
|
||||||
@@ -7,29 +9,28 @@ const ChatBot = ({ existingConversation }) => {
|
|||||||
sender: 'bot',
|
sender: 'bot',
|
||||||
text: 'Hai! Saya Alle, asisten virtual Dermalounge Clinic. Ada yang bisa Alle bantu hari ini?',
|
text: 'Hai! Saya Alle, asisten virtual Dermalounge Clinic. Ada yang bisa Alle bantu hari ini?',
|
||||||
time: getTime(),
|
time: getTime(),
|
||||||
quickReplies: [
|
quickReplies: ['Konsultasi Estetik', 'Konsultasi Kulit & Kelamin'],
|
||||||
'Konsultasi Estetik',
|
|
||||||
'Konsultasi Kulit & Kelamin'
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isOpenCamera = searchParams.get('camera') === 'open';
|
||||||
|
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState('');
|
||||||
|
|
||||||
|
|
||||||
const [isPoppedUp, setIsPoppedUp] = useState('');
|
const [isPoppedUp, setIsPoppedUp] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [phoneNumber, setPhoneNumber] = useState('');
|
const [phoneNumber, setPhoneNumber] = useState('');
|
||||||
useEffect(() => {
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (existingConversation && existingConversation.length > 0) {
|
if (existingConversation && existingConversation.length > 0) {
|
||||||
setMessages(existingConversation);
|
setMessages(existingConversation);
|
||||||
}
|
}
|
||||||
}, [existingConversation])
|
}, [existingConversation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!localStorage.getItem('session')) {
|
if (!sessionStorage.getItem('session')) {
|
||||||
function generateUUID() {
|
function generateUUID() {
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||||
const r = Math.random() * 16 | 0;
|
const r = Math.random() * 16 | 0;
|
||||||
@@ -40,96 +41,158 @@ const ChatBot = ({ existingConversation }) => {
|
|||||||
|
|
||||||
const sessionId = generateUUID();
|
const sessionId = generateUUID();
|
||||||
const dateNow = new Date().toISOString();
|
const dateNow = new Date().toISOString();
|
||||||
|
sessionStorage.setItem('session', JSON.stringify({ sessionId, lastSeen: dateNow }));
|
||||||
localStorage.setItem('session', JSON.stringify({ sessionId: sessionId, lastSeen: dateNow }))
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function base64ToFile(base64Data, filename) {
|
||||||
|
const arr = base64Data.split(',');
|
||||||
|
const mime = arr[0].match(/:(.*?);/)[1];
|
||||||
|
const bstr = atob(arr[1]);
|
||||||
|
let n = bstr.length;
|
||||||
|
const u8arr = new Uint8Array(n);
|
||||||
|
while (n--) {
|
||||||
|
u8arr[n] = bstr.charCodeAt(n);
|
||||||
|
}
|
||||||
|
return new File([u8arr], filename, { type: mime });
|
||||||
|
}
|
||||||
|
|
||||||
|
const askToBot = async ({ type = 'text', content, tryCount = 0 }) => {
|
||||||
|
const session = JSON.parse(sessionStorage.getItem('session'));
|
||||||
|
if (!session || !session.sessionId) return;
|
||||||
|
|
||||||
|
let body;
|
||||||
|
let headers;
|
||||||
|
const isBase64Image = type === 'image' && typeof content === 'string' && content.startsWith('data:image/');
|
||||||
|
|
||||||
|
if (isBase64Image) {
|
||||||
|
const file = base64ToFile(content, 'photo.jpg');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('sessionId', session.sessionId);
|
||||||
|
formData.append('lastSeen', new Date().toISOString());
|
||||||
|
formData.append('name', session.name || '');
|
||||||
|
formData.append('phoneNumber', session.phoneNumber || '');
|
||||||
|
formData.append('type', type);
|
||||||
|
formData.append('image', file);
|
||||||
|
body = formData;
|
||||||
|
headers = {};
|
||||||
|
} else {
|
||||||
|
body = JSON.stringify({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
lastSeen: new Date().toISOString(),
|
||||||
|
name: session.name,
|
||||||
|
phoneNumber: session.phoneNumber,
|
||||||
|
pertanyaan: content,
|
||||||
|
type: type,
|
||||||
|
});
|
||||||
|
headers = { 'Content-Type': 'application/json' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://bot.kediritechnopark.com/webhook/master-agent/ask', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (tryCount < 3) {
|
||||||
|
return new Promise((resolve) =>
|
||||||
|
setTimeout(() => resolve(askToBot({ type, content, tryCount: tryCount + 1 })), 3000)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error('Bot unavailable:', error);
|
||||||
|
return { jawaban: 'Maaf saya sedang tidak tersedia sekarang, coba lagi nanti' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadImage = async (img) => {
|
||||||
|
const session = JSON.parse(sessionStorage.getItem('session'));
|
||||||
|
if (!session?.name || !session?.phoneNumber) {
|
||||||
|
setIsPoppedUp(img); // simpan gambar untuk dikirim setelah user isi data
|
||||||
|
setSearchParams({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchParams({});
|
||||||
|
const newMessages = [
|
||||||
|
...messages,
|
||||||
|
{ sender: 'user', img: img, time: getTime() },
|
||||||
|
];
|
||||||
|
setMessages(newMessages);
|
||||||
|
setIsLoading('Menganalisa gambar anda...');
|
||||||
|
|
||||||
|
const data = await askToBot({ type: 'image', content: img });
|
||||||
|
const botAnswer = data.jawaban || 'Maaf, saya tidak bisa menganalisis gambar tersebut.';
|
||||||
|
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ sender: 'bot', text: botAnswer, time: getTime() },
|
||||||
|
]);
|
||||||
|
setIsLoading('');
|
||||||
|
};
|
||||||
|
|
||||||
const sendMessage = async (textOverride = null, name, phoneNumber, tryCount = 0) => {
|
const sendMessage = async (textOverride = null, name, phoneNumber, tryCount = 0) => {
|
||||||
const message = textOverride || input.trim();
|
const message = textOverride || input.trim();
|
||||||
if (message === '') return;
|
if (message === '') return;
|
||||||
|
|
||||||
const session = JSON.parse(localStorage.getItem('session'));
|
const session = JSON.parse(sessionStorage.getItem('session'));
|
||||||
|
|
||||||
if ((!session || !session.name || !session.phoneNumber) && messages.length > 2) {
|
if ((!session || !session.name || !session.phoneNumber) && messages.length > 2) {
|
||||||
setIsPoppedUp(message); // munculkan form input
|
setIsPoppedUp(message);
|
||||||
setInput('');
|
setInput('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show user's message immediately
|
const newMessages = [...messages, { sender: 'user', text: message, time: getTime() }];
|
||||||
const newMessages = [
|
|
||||||
...messages,
|
|
||||||
{ sender: 'user', text: message, time: getTime() },
|
|
||||||
];
|
|
||||||
|
|
||||||
setMessages(newMessages);
|
setMessages(newMessages);
|
||||||
setInput('');
|
setInput('');
|
||||||
|
setIsLoading('Mengetik...');
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
try {
|
||||||
// Send to backend
|
const data = await askToBot({ type: 'text', content: message, tryCount });
|
||||||
const response = await fetch('https://bot.kediritechnopark.com/webhook/master-agent/ask', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ pertanyaan: message, sessionId: JSON.parse(localStorage.getItem('session')).sessionId, lastSeen: new Date().toISOString(), name: JSON.parse(localStorage.getItem('session')).name, phoneNumber: JSON.parse(localStorage.getItem('session')).phoneNumber }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.log(data)
|
|
||||||
// Assuming your backend sends back something like: { answer: "text" }
|
|
||||||
// Adjust this according to your actual response shape
|
|
||||||
const botAnswer = data.jawaban || 'Maaf saya sedang tidak tersedia sekarang, coba lagi nanti';
|
const botAnswer = data.jawaban || 'Maaf saya sedang tidak tersedia sekarang, coba lagi nanti';
|
||||||
|
|
||||||
// Add bot's reply
|
|
||||||
setMessages(prev => [
|
setMessages(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
{ sender: 'bot', text: botAnswer, time: getTime() },
|
{ sender: 'bot', text: botAnswer, time: getTime() },
|
||||||
]);
|
]);
|
||||||
|
setIsLoading('');
|
||||||
setIsLoading(false);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(tryCount)
|
console.error('Error sending message:', error);
|
||||||
if (tryCount > 3) {
|
if (tryCount >= 3) {
|
||||||
// Add bot's error reply
|
|
||||||
setMessages(prev => [
|
setMessages(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
{ sender: 'bot', text: 'Maaf saya sedang tidak tersedia sekarang, coba lagi nanti', time: getTime() },
|
{ sender: 'bot', text: 'Maaf saya sedang tidak tersedia sekarang, coba lagi nanti', time: getTime() },
|
||||||
]);
|
]);
|
||||||
setIsLoading(false);
|
setIsLoading('');
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
setTimeout(() => sendMessage(message, name, phoneNumber, tryCount + 1), 3000);
|
setTimeout(() => sendMessage(message, name, phoneNumber, tryCount + 1), 3000);
|
||||||
|
}
|
||||||
console.error('Fetch error:', error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatBoldText(text) {
|
function formatBoldText(text) {
|
||||||
const parts = text.split(/(\*\*[^\*]+\*\*)/g);
|
const parts = text.split(/(\*\*[^\*]+\*\*)/g);
|
||||||
|
|
||||||
return parts.flatMap((part, index) => {
|
return parts.flatMap((part, index) => {
|
||||||
const elements = [];
|
const elements = [];
|
||||||
|
|
||||||
if (part.startsWith('**') && part.endsWith('**')) {
|
if (part.startsWith('**') && part.endsWith('**')) {
|
||||||
// Bold text
|
|
||||||
part = part.slice(2, -2);
|
part = part.slice(2, -2);
|
||||||
part.split('\n').forEach((line, i) => {
|
part.split('\n').forEach((line, i) => {
|
||||||
if (i > 0) elements.push(<br key={`br-bold-${index}-${i}`} />);
|
if (i > 0) elements.push(<br key={`br-bold-${index}-${i}`} />);
|
||||||
elements.push(<strong key={`bold-${index}-${i}`}>{line}</strong>);
|
elements.push(<strong key={`bold-${index}-${i}`}>{line}</strong>);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Normal text
|
|
||||||
part.split('\n').forEach((line, i) => {
|
part.split('\n').forEach((line, i) => {
|
||||||
if (i > 0) elements.push(<br key={`br-${index}-${i}`} />);
|
if (i > 0) elements.push(<br key={`br-${index}-${i}`} />);
|
||||||
elements.push(<span key={`text-${index}-${i}`}>{line}</span>);
|
elements.push(<span key={`text-${index}-${i}`}>{line}</span>);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements;
|
return elements;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.chatContainer}>
|
<div className={styles.chatContainer}>
|
||||||
<div className={styles.chatHeader}>
|
<div className={styles.chatHeader}>
|
||||||
@@ -138,41 +201,42 @@ function formatBoldText(text) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chatBody}>
|
<div className={styles.chatBody}>
|
||||||
|
{isLoading !== '' && (
|
||||||
{isLoading && (
|
|
||||||
<div className={`${styles.messageRow} ${styles.bot}`}>
|
<div className={`${styles.messageRow} ${styles.bot}`}>
|
||||||
<div className={`${styles.message} ${styles.bot}`}>
|
<div className={`${styles.message} ${styles.bot}`}>
|
||||||
<em>Mengetik...</em>
|
<em>{isLoading}</em>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.slice().reverse().map((msg, index) => (
|
{messages.slice().reverse().map((msg, index) => (
|
||||||
<div
|
<div key={index} className={`${styles.messageRow} ${styles[msg.sender]}`}>
|
||||||
key={index}
|
|
||||||
className={`${styles.messageRow} ${styles[msg.sender]}`}
|
|
||||||
>
|
|
||||||
<div className={`${styles.message} ${styles[msg.sender]}`}>
|
<div className={`${styles.message} ${styles[msg.sender]}`}>
|
||||||
{msg.sender !== 'bot'
|
{msg.sender !== 'bot' ? (
|
||||||
? msg.text
|
msg.text ? msg.text : <img style={{ maxHeight: '300px', maxWidth: '240px', borderRadius: '12px' }} src={msg.img} />
|
||||||
: (() => {
|
) : (
|
||||||
|
(() => {
|
||||||
try {
|
try {
|
||||||
return formatBoldText(msg.text); // Apply formatting here
|
return formatBoldText(msg.text);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return msg.text;
|
return msg.text;
|
||||||
}
|
}
|
||||||
})()}
|
})()
|
||||||
|
)}
|
||||||
{msg.quickReplies && (
|
{msg.quickReplies && (
|
||||||
<div className={styles.quickReplies}>
|
<div className={styles.quickReplies}>
|
||||||
{msg.quickReplies.map((reply, i) => (
|
{msg.quickReplies.map((reply, i) => (
|
||||||
<div
|
<div key={i} className={styles.quickReply} onClick={() => sendMessage(reply)}>
|
||||||
key={i}
|
|
||||||
className={styles.quickReply}
|
|
||||||
onClick={() => sendMessage(reply)}
|
|
||||||
>
|
|
||||||
{reply}
|
{reply}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<div
|
||||||
|
className={styles.quickReply}
|
||||||
|
onClick={() => setSearchParams({ camera: 'open' })}
|
||||||
|
style={{ color: 'white', backgroundColor: '#075e54', display: 'flex', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<img style={{ marginRight: '5px', height: '14px', filter: 'invert(1)' }} src={'/camera.png'} />
|
||||||
|
Analisa Gambar
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.timestamp}>{msg.time}</div>
|
<div className={styles.timestamp}>{msg.time}</div>
|
||||||
@@ -181,21 +245,24 @@ function formatBoldText(text) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chatInput} /*/style={{ visibility: readOnly ? 'hidden' : 'visible'}}/*/>
|
<div className={styles.chatInput}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Ketik pesan..."
|
placeholder="Ketik pesan..."
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
|
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
|
||||||
disabled={isLoading}
|
disabled={isLoading !== ''}
|
||||||
/>
|
/>
|
||||||
|
<button onClick={() => sendMessage()} style={{ marginLeft: '-40px' }} disabled={isLoading !== ''}>
|
||||||
<button onClick={() => sendMessage()} disabled={isLoading}>
|
<img src="/send.png" alt="Kirim" style={{ height: '20px', filter: 'invert(1)' }} />
|
||||||
Kirim
|
</button>
|
||||||
|
<button onClick={() => setSearchParams({ camera: 'open' })} disabled={isLoading !== ''}>
|
||||||
|
<img src="/camera.png" alt="Kamera" style={{ height: '18px', filter: 'invert(1)' }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isPoppedUp != '' &&
|
|
||||||
|
{isPoppedUp !== '' && (
|
||||||
<div className={styles.PopUp}>
|
<div className={styles.PopUp}>
|
||||||
<div className={`${styles.message} ${styles['bot']}`}>
|
<div className={`${styles.message} ${styles['bot']}`}>
|
||||||
Untuk bisa membantu Anda lebih jauh, boleh saya tahu nama dan nomor telepon Anda?
|
Untuk bisa membantu Anda lebih jauh, boleh saya tahu nama dan nomor telepon Anda?
|
||||||
@@ -204,7 +271,6 @@ function formatBoldText(text) {
|
|||||||
<input
|
<input
|
||||||
className={styles.quickReply}
|
className={styles.quickReply}
|
||||||
placeholder="Nama Lengkapmu"
|
placeholder="Nama Lengkapmu"
|
||||||
onFocus={() => console.log('Nama focused')}
|
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
maxLength={40}
|
maxLength={40}
|
||||||
@@ -221,28 +287,26 @@ function formatBoldText(text) {
|
|||||||
value={phoneNumber}
|
value={phoneNumber}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
// Hanya angka, maksimal 11 karakter
|
|
||||||
if (/^\d{0,11}$/.test(value)) {
|
if (/^\d{0,11}$/.test(value)) {
|
||||||
setPhoneNumber(value);
|
setPhoneNumber(value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onFocus={() => console.log('Telepon focused')}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.nextButton}
|
className={styles.nextButton}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (name.length > 2 && phoneNumber.length >= 10) {
|
if (name.length > 2 && phoneNumber.length >= 10) {
|
||||||
const sessionData = JSON.parse(localStorage.getItem('session')) || {};
|
const sessionData = JSON.parse(sessionStorage.getItem('session')) || {};
|
||||||
|
|
||||||
sessionData.name = name;
|
sessionData.name = name;
|
||||||
sessionData.phoneNumber = phoneNumber;
|
sessionData.phoneNumber = phoneNumber;
|
||||||
|
sessionStorage.setItem('session', JSON.stringify(sessionData));
|
||||||
localStorage.setItem('session', JSON.stringify(sessionData));
|
setIsPoppedUp('');
|
||||||
setIsPoppedUp('')
|
if (typeof isPoppedUp === 'string' && isPoppedUp.startsWith('data:image/')) {
|
||||||
sendMessage(isPoppedUp)
|
handleUploadImage(isPoppedUp);
|
||||||
|
} else {
|
||||||
|
sendMessage(isPoppedUp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -251,7 +315,14 @@ function formatBoldText(text) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
|
|
||||||
|
{isOpenCamera && (
|
||||||
|
<Camera
|
||||||
|
handleClose={() => setSearchParams({})}
|
||||||
|
handleUploadImage={(e) => handleUploadImage(e)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
243
src/Dashboard.js
243
src/Dashboard.js
@@ -15,13 +15,19 @@ import NotificationPrompt from './NotificationPrompt';
|
|||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
const chartRef = useRef(null);
|
const weeklyChartRef = useRef(null);
|
||||||
const chartInstanceRef = useRef(null);
|
const allTimeChartRef = useRef(null);
|
||||||
|
const weeklyChartInstanceRef = useRef(null);
|
||||||
|
const allTimeChartInstanceRef = useRef(null);
|
||||||
|
|
||||||
|
const [weeklyData, setWeeklyData] = useState([]);
|
||||||
|
const [allTimeData, setAllTimeData] = useState([]);
|
||||||
|
const [faceAnalystList, setFaceAnalystList] = useState([]);
|
||||||
|
|
||||||
const [conversations, setConversations] = useState([]);
|
const [conversations, setConversations] = useState([]);
|
||||||
const [followUps, setFollowUps] = useState([]);
|
const [followUps, setFollowUps] = useState([]);
|
||||||
const [discussedTopics, setDiscussedTopics] = useState([]);
|
const [discussedTopics, setDiscussedTopics] = useState([]);
|
||||||
const [modalContent, setModalContent] = useState(null);
|
const [modalContent, setModalContent] = useState(null);
|
||||||
const [rawData, setRawData] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true); // ⬅️ Tambahkan state loading
|
const [loading, setLoading] = useState(true); // ⬅️ Tambahkan state loading
|
||||||
const [fileList, setFileList] = useState([]);
|
const [fileList, setFileList] = useState([]);
|
||||||
const [selectedKeys, setSelectedKeys] = useState([]);
|
const [selectedKeys, setSelectedKeys] = useState([]);
|
||||||
@@ -177,6 +183,115 @@ const Dashboard = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Helper parse data function
|
||||||
|
function parseGraphData(graph) {
|
||||||
|
const prefixLabelMap = {
|
||||||
|
WEB: 'Web App',
|
||||||
|
TGG: 'Telegram',
|
||||||
|
WGG: 'Whatsapp',
|
||||||
|
IGG: 'Instagram',
|
||||||
|
};
|
||||||
|
const prefixColors = {
|
||||||
|
WEB: { border: '#e2b834', background: 'rgba(226,184,52,0.6)' },
|
||||||
|
TGG: { border: '#24A1DE', background: 'rgba(36,161,222,0.6)' },
|
||||||
|
WGG: { border: '#25d366', background: 'rgba(37,211,102,0.6)' },
|
||||||
|
IGG: { border: '#d62976', background: 'rgba(214,41,118,0.6)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawDataArray = Object.entries(graph).map(([date, sesi]) => ({
|
||||||
|
hour: date,
|
||||||
|
sesi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
rawDataArray.sort((a, b) => new Date(a.hour) - new Date(b.hour));
|
||||||
|
|
||||||
|
const prefixes = Object.keys(prefixLabelMap);
|
||||||
|
|
||||||
|
// Format label: tanggal + nama bulan singkat Indonesia
|
||||||
|
const bulanIndoSingkat = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
|
||||||
|
const labels = rawDataArray.map(d => {
|
||||||
|
const date = new Date(d.hour);
|
||||||
|
const tgl = date.getDate().toString().padStart(2, '0');
|
||||||
|
const bln = bulanIndoSingkat[date.getMonth()];
|
||||||
|
return `${tgl} ${bln}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts = {};
|
||||||
|
prefixes.forEach(prefix => {
|
||||||
|
counts[prefix] = labels.map(() => 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
rawDataArray.forEach(({ sesi }, index) => {
|
||||||
|
prefixes.forEach(prefix => {
|
||||||
|
if (typeof sesi[prefix] === 'number') {
|
||||||
|
counts[prefix][index] = sesi[prefix];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const datasets = prefixes.map(prefix => ({
|
||||||
|
label: prefixLabelMap[prefix],
|
||||||
|
data: counts[prefix],
|
||||||
|
borderColor: prefixColors[prefix].border,
|
||||||
|
backgroundColor: prefixColors[prefix].background,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { labels, datasets };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Effect buat render weekly chart
|
||||||
|
useEffect(() => {
|
||||||
|
if (!weeklyData.labels || !weeklyChartRef.current) return;
|
||||||
|
|
||||||
|
if (weeklyChartInstanceRef.current) {
|
||||||
|
weeklyChartInstanceRef.current.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = weeklyChartRef.current.getContext('2d');
|
||||||
|
weeklyChartInstanceRef.current = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: weeklyData.labels,
|
||||||
|
datasets: weeklyData.datasets,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom' },
|
||||||
|
},
|
||||||
|
scales: { y: { beginAtZero: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [weeklyData]);
|
||||||
|
|
||||||
|
// Effect buat render all-time chart
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allTimeData.labels || !allTimeChartRef.current) return;
|
||||||
|
|
||||||
|
if (allTimeChartInstanceRef.current) {
|
||||||
|
allTimeChartInstanceRef.current.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = allTimeChartRef.current.getContext('2d');
|
||||||
|
allTimeChartInstanceRef.current = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: allTimeData.labels,
|
||||||
|
datasets: allTimeData.datasets,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom' },
|
||||||
|
},
|
||||||
|
scales: { y: { beginAtZero: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [allTimeData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
@@ -200,18 +315,29 @@ const Dashboard = () => {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
try{
|
||||||
console.log(data);
|
console.log(data);
|
||||||
setDiscussedTopics(data[0]?.graph[0]?.json?.result?.topics)
|
setDiscussedTopics(data[0]?.graph[0]?.json?.result?.topics)
|
||||||
|
setFaceAnalystList(data[0]?.graph[0]?.json?.result?.analyst_counter);
|
||||||
|
|
||||||
setFollowUps(data[0]?.graph[0]?.json?.result?.interested_users)
|
setFollowUps(data[0]?.graph[0]?.json?.result?.interested_users)
|
||||||
setFileList(data[0]?.files)
|
setFileList(data[0]?.files)
|
||||||
setUpdateDetected(data[1]?.updateDetected)
|
setUpdateDetected(data[1]?.updateDetected)
|
||||||
|
|
||||||
|
|
||||||
|
const result = data[0].graph[0].json.result;
|
||||||
|
|
||||||
|
setWeeklyData(parseGraphData(result.weekly_graph || {}));
|
||||||
|
setAllTimeData(parseGraphData(result.all_time_graph || {}));
|
||||||
|
|
||||||
const graphObj = data[0]?.graph[0]?.json?.result?.graph;
|
const graphObj = data[0]?.graph[0]?.json?.result?.graph;
|
||||||
console.log(graphObj)
|
console.log(graphObj)
|
||||||
const rawDataArray = Object.entries(graphObj).map(([hour, sesi]) => ({
|
const rawDataArray = Object.entries(graphObj).map(([hour, sesi]) => ({
|
||||||
hour,
|
hour,
|
||||||
sesi,
|
sesi,
|
||||||
}));
|
}));
|
||||||
setRawData(rawDataArray);
|
|
||||||
let totalSessions = new Set();
|
let totalSessions = new Set();
|
||||||
let botMessages = 0;
|
let botMessages = 0;
|
||||||
|
|
||||||
@@ -228,6 +354,10 @@ const Dashboard = () => {
|
|||||||
totalChats: totalSessions.size,
|
totalChats: totalSessions.size,
|
||||||
botMessages,
|
botMessages,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
catch{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(false); // ⬅️ Setelah berhasil, hilangkan loading
|
setLoading(false); // ⬅️ Setelah berhasil, hilangkan loading
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -310,101 +440,9 @@ const Dashboard = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openTopicsModal = () => {
|
const openTopicsModal = () => {
|
||||||
setModalContent(<DiscussedTopics topics={discussedTopics} />);
|
setModalContent(<DiscussedTopics topics={discussedTopics} faceAnalystList={faceAnalystList} />);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!rawData.length) return;
|
|
||||||
|
|
||||||
const ctx = chartRef.current?.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
if (chartInstanceRef.current) {
|
|
||||||
chartInstanceRef.current.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefixLabelMap = {
|
|
||||||
WEB: 'Web App',
|
|
||||||
TGG: 'Telegram',
|
|
||||||
WGG: 'Whatsapp',
|
|
||||||
IGG: 'Instagram',
|
|
||||||
};
|
|
||||||
|
|
||||||
const prefixColors = {
|
|
||||||
WEB: { border: '#e2b834', background: 'rgba(226,184,52,0.6)' },
|
|
||||||
TGG: { border: '#24A1DE', background: 'rgba(36,161,222,0.6)' },
|
|
||||||
WGG: { border: '#25d366', background: 'rgba(37,211,102,0.6)' },
|
|
||||||
IGG: { border: '#d62976', background: 'rgba(214,41,118,0.6)' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const prefixes = Object.keys(prefixLabelMap);
|
|
||||||
const parsedHours = rawData.map(d => new Date(d.hour));
|
|
||||||
parsedHours.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
// Extract only the date (no timezone shifting)
|
|
||||||
const getDateStr = date => date.getFullYear() + '-' + (date.getMonth() + 1).toString().padStart(2, '0') + '-' + date.getDate().toString().padStart(2, '0');
|
|
||||||
|
|
||||||
|
|
||||||
const hours = parsedHours.map((date, index) => {
|
|
||||||
const timeStr = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0');
|
|
||||||
return index === parsedHours.length - 1 ? 'Now' : timeStr;
|
|
||||||
});
|
|
||||||
|
|
||||||
const counts = {};
|
|
||||||
prefixes.forEach(prefix => {
|
|
||||||
counts[prefix] = hours.map(() => 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
rawData.forEach(({ sesi }, index) => {
|
|
||||||
prefixes.forEach(prefix => {
|
|
||||||
if (Array.isArray(sesi[prefix])) {
|
|
||||||
counts[prefix][index] = sesi[prefix].length;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const datasets = prefixes.map(prefix => ({
|
|
||||||
label: prefixLabelMap[prefix],
|
|
||||||
data: counts[prefix],
|
|
||||||
borderColor: prefixColors[prefix].border,
|
|
||||||
backgroundColor: prefixColors[prefix].background,
|
|
||||||
fill: true,
|
|
||||||
tension: 0.3,
|
|
||||||
}));
|
|
||||||
|
|
||||||
chartInstanceRef.current = new Chart(ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: hours,
|
|
||||||
datasets,
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: true,
|
|
||||||
position: 'bottom',
|
|
||||||
labels: {
|
|
||||||
boxWidth: 15
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
ticks: {
|
|
||||||
font: {
|
|
||||||
size: 10, // 👈 set your desired font size here
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [rawData]);
|
|
||||||
|
|
||||||
const handleDeleteFile = async (key) => {
|
const handleDeleteFile = async (key) => {
|
||||||
if (!window.confirm(`Yakin ingin menghapus "${key}"?`)) return;
|
if (!window.confirm(`Yakin ingin menghapus "${key}"?`)) return;
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
@@ -634,7 +672,16 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
<div className={styles.chartSection}>
|
<div className={styles.chartSection}>
|
||||||
<h2 className={styles.chartTitle}>Interactions</h2>
|
<h2 className={styles.chartTitle}>Interactions</h2>
|
||||||
<canvas ref={chartRef}></canvas>
|
|
||||||
|
<section style={{ marginBottom: '40px' }}>
|
||||||
|
<h2 className={styles.chartTitle}>Weekly Graph</h2>
|
||||||
|
<canvas ref={weeklyChartRef}></canvas>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className={styles.chartTitle}>All Time Graph</h2>
|
||||||
|
<canvas ref={allTimeChartRef}></canvas>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.chartSection}>
|
<div className={styles.chartSection}>
|
||||||
<h2 className={styles.chartTitle}>Update AI data</h2>
|
<h2 className={styles.chartTitle}>Update AI data</h2>
|
||||||
|
|||||||
@@ -1,19 +1,104 @@
|
|||||||
// DiscussedTopics.js
|
// DiscussedTopics.js
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import styles from './DiscussedTopics.module.css';
|
import styles from './DiscussedTopics.module.css';
|
||||||
|
|
||||||
const DiscussedTopics = ({ topics }) => {
|
const IMG_BASE = 'https://bot.kediritechnopark.com/webhook';
|
||||||
|
|
||||||
|
const DiscussedTopics = ({ topics = [], faceAnalystList = [] }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState('topics'); // 'topics' | 'face'
|
||||||
|
const [imageSrcs, setImageSrcs] = useState({}); // { [id]: dataUrl }
|
||||||
|
const [imgErrors, setImgErrors] = useState({}); // { [id]: true }
|
||||||
|
|
||||||
|
// super simple: always expect [{ data: "<base64>" }]
|
||||||
|
const fetchImageSrc = async (id) => {
|
||||||
|
const token =
|
||||||
|
localStorage.getItem('token') ||
|
||||||
|
localStorage.getItem('access_token') ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
const resp = await fetch(
|
||||||
|
`${IMG_BASE}/16f3a63d-0271-4f03-b1fb-14a051c57285/face-analysis-image/${id}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
|
||||||
|
const json = await resp.json();
|
||||||
|
const base64 = json?.[0]?.data;
|
||||||
|
if (!base64) throw new Error('No base64 data');
|
||||||
|
|
||||||
|
// Your backend returns JPEG base64, so just prefix it:
|
||||||
|
return `data:image/jpeg;base64,${base64}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch images when Face tab becomes active
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'face' || faceAnalystList.length === 0) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const nextImages = {};
|
||||||
|
const nextErrors = {};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
faceAnalystList.map(async (item) => {
|
||||||
|
const id = item?.id;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const src = await fetchImageSrc(id);
|
||||||
|
nextImages[id] = src;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to load image for ${id}`, e);
|
||||||
|
nextErrors[id] = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setImageSrcs(nextImages);
|
||||||
|
setImgErrors(nextErrors);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [activeTab, faceAnalystList]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h2 className={styles.title}>Top Topic</h2>
|
<div className={styles.tabButtons}>
|
||||||
|
<button
|
||||||
|
className={`${styles.tabButton} ${activeTab === 'topics' ? styles.activeTab : ''}`}
|
||||||
|
onClick={() => setActiveTab('topics')}
|
||||||
|
>
|
||||||
|
Top Topic
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.tabButton} ${activeTab === 'face' ? styles.activeTab : ''}`}
|
||||||
|
onClick={() => setActiveTab('face')}
|
||||||
|
>
|
||||||
|
Face Analyst Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className={styles.resultCount}>
|
<div className={styles.resultCount}>
|
||||||
{topics.length} topik
|
{activeTab === 'topics'
|
||||||
|
? `${topics.length} topik`
|
||||||
|
: `${faceAnalystList.length} report`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
{topics.length === 0 ? (
|
{activeTab === 'topics' ? (
|
||||||
|
topics.length === 0 ? (
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<div className={styles.emptyIcon}>💬</div>
|
<div className={styles.emptyIcon}>💬</div>
|
||||||
<p>Discussed Topic Is Empty</p>
|
<p>Discussed Topic Is Empty</p>
|
||||||
@@ -30,6 +115,44 @@ const DiscussedTopics = ({ topics }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
)
|
||||||
|
) : faceAnalystList.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyIcon}>🧑⚕️</div>
|
||||||
|
<p>No Face Analyst Report</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
faceAnalystList.map((item, idx) => {
|
||||||
|
const id = item?.id;
|
||||||
|
const src = id ? imageSrcs[id] : null;
|
||||||
|
const hadError = id ? imgErrors[id] : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className={styles.card}>
|
||||||
|
<div className={styles.cardContent}>
|
||||||
|
<h3 className={styles.topicName}>{item.name}</h3>
|
||||||
|
|
||||||
|
{src && (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={`Face analysis ${item.name || id}`}
|
||||||
|
className={styles.imagePreview}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!src && !hadError && (
|
||||||
|
<div className={styles.imageLoading}>Loading image…</div>
|
||||||
|
)}
|
||||||
|
{hadError && (
|
||||||
|
<div className={styles.imageError}>Couldn’t load image</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className={styles.description}>{item.description}</p>
|
||||||
|
<p className={styles.phone}>📞 {item.phone_number}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -185,3 +185,39 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tabButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabButton {
|
||||||
|
background-color: #eee;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeTab {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imagePreview{
|
||||||
|
border-radius: 15px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
174
src/FollowUps.js
174
src/FollowUps.js
@@ -1,7 +1,67 @@
|
|||||||
// FollowUps.js
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import styles from './FollowUps.module.css';
|
import styles from './FollowUps.module.css';
|
||||||
|
|
||||||
|
// Prefix → ikon (bebas ganti jadi SVG/logo sendiri)
|
||||||
|
const PREFIX_ICON = {
|
||||||
|
WGG: 'WHATSAPP', // WhatsApp
|
||||||
|
TGG: 'TELEGRAM', // Telegram
|
||||||
|
WEB: 'WEBSITE', // Web
|
||||||
|
IGG: 'INSTAGRAM', // Instagram
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: parse, normalisasi, dan validasi nomor Indonesia
|
||||||
|
function parseContactInfo(raw) {
|
||||||
|
if (!raw) return { prefix: null, digits: null, display: raw || '', isValid: false };
|
||||||
|
|
||||||
|
let text = String(raw).trim();
|
||||||
|
let prefix = null;
|
||||||
|
|
||||||
|
// Deteksi prefix di awal (dengan/tanpa '-')
|
||||||
|
const m = text.match(/^(WGG|TGG|WEB|IGG)-?/i);
|
||||||
|
if (m) {
|
||||||
|
prefix = m[1].toUpperCase();
|
||||||
|
text = text.slice(m[0].length); // buang prefix dari display
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bersihkan: sisakan angka dan tanda '+'
|
||||||
|
let cleaned = text.replace(/[^\d+]/g, '');
|
||||||
|
|
||||||
|
// Normalisasi: buang '+' untuk proses
|
||||||
|
if (cleaned.startsWith('+')) cleaned = cleaned.slice(1);
|
||||||
|
|
||||||
|
// Jika 08… → ganti ke 62…
|
||||||
|
if (cleaned.startsWith('0')) cleaned = `62${cleaned.slice(1)}`;
|
||||||
|
|
||||||
|
// Hanya digit
|
||||||
|
let digits = cleaned.replace(/\D/g, '');
|
||||||
|
// Setelah menghasilkan `digits` yang hanya angka
|
||||||
|
if (digits.startsWith('6262')) {
|
||||||
|
// buang '62' ekstra di depan
|
||||||
|
digits = digits.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Juga bereskan kasus '6208...' (orang nulis '62' + nomor lokal '08...')
|
||||||
|
if (digits.startsWith('620')) {
|
||||||
|
digits = '62' + digits.slice(3); // hasilnya '628...'
|
||||||
|
}
|
||||||
|
|
||||||
|
// (opsional) Kalau user pakai format internasional '0062...' → jadikan ke '62...'
|
||||||
|
if (digits.startsWith('00')) {
|
||||||
|
digits = digits.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pastikan diawali 62
|
||||||
|
if (!digits.startsWith('62') && /^\d+$/.test(digits)) {
|
||||||
|
// Jika bukan 62, biarkan apa adanya (mungkin sudah nomor lokal lain),
|
||||||
|
// tapi validator di bawah akan menandai tidak valid.
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = /^62\d{7,13}$/.test(digits); // 62 + 7–13 digit
|
||||||
|
const display = isValid ? `+${digits}` : (text.trim() || raw);
|
||||||
|
|
||||||
|
return { prefix, digits, display, isValid };
|
||||||
|
}
|
||||||
|
|
||||||
const FollowUps = ({ data: initialData }) => {
|
const FollowUps = ({ data: initialData }) => {
|
||||||
const [data, setData] = useState(initialData);
|
const [data, setData] = useState(initialData);
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
@@ -14,9 +74,7 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
try {
|
try {
|
||||||
await fetch('https://bot.kediritechnopark.com/webhook/set-follow-up', {
|
await fetch('https://bot.kediritechnopark.com/webhook/set-follow-up', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
isfollowup: user.isfollowup ? 'success' : 'followup'
|
isfollowup: user.isfollowup ? 'success' : 'followup'
|
||||||
@@ -37,8 +95,11 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buka WA chat pakai nomor yang sudah dinormalisasi (jika valid)
|
||||||
if (!user.isfollowup) {
|
if (!user.isfollowup) {
|
||||||
window.open(`https://api.whatsapp.com/send?phone=${user.contact_info}`, '_blank');
|
const parsed = parseContactInfo(user.contact_info);
|
||||||
|
const waNumber = parsed.isValid ? parsed.digits : user.contact_info;
|
||||||
|
window.open(`https://api.whatsapp.com/send?phone=${waNumber}`, '_blank');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to set follow-up:', error);
|
console.error('Failed to set follow-up:', error);
|
||||||
@@ -46,38 +107,58 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Gabungkan data berdasarkan contact_info dan hilangkan note yang sama berurutan
|
||||||
|
const mergedDataMap = new Map();
|
||||||
|
data.forEach(user => {
|
||||||
|
const key = user.contact_info;
|
||||||
|
if (!mergedDataMap.has(key)) {
|
||||||
|
mergedDataMap.set(key, {
|
||||||
|
...user,
|
||||||
|
notesList: [{ note: user.notes, created_at: user.created_at }]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const existing = mergedDataMap.get(key);
|
||||||
|
const lastNote = existing.notesList[existing.notesList.length - 1];
|
||||||
|
if (!lastNote || lastNote.note !== user.notes) {
|
||||||
|
existing.notesList.push({ note: user.notes, created_at: user.created_at });
|
||||||
|
}
|
||||||
|
existing.issuccess = existing.issuccess || user.issuccess;
|
||||||
|
existing.isfollowup = existing.issuccess ? false : (existing.isfollowup || user.isfollowup);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedData = Array.from(mergedDataMap.values());
|
||||||
|
|
||||||
// Filter & Sort
|
// Filter & Sort
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const filteredData = data
|
const filteredData = mergedData
|
||||||
.filter(user => {
|
.filter(user => {
|
||||||
switch (statusFilter) {
|
switch (statusFilter) {
|
||||||
case 'pending':
|
case 'pending': return !user.isfollowup && !user.issuccess;
|
||||||
return !user.isfollowup && !user.issuccess;
|
case 'inProgress': return user.isfollowup && !user.issuccess;
|
||||||
case 'inProgress':
|
case 'success': return user.issuccess;
|
||||||
return user.isfollowup && !user.issuccess;
|
default: return true;
|
||||||
case 'success':
|
|
||||||
return user.issuccess;
|
|
||||||
default:
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(user => {
|
.filter(user => {
|
||||||
const created = new Date(user.created_at);
|
const latestNote = user.notesList[user.notesList.length - 1];
|
||||||
|
const created = new Date(latestNote.created_at);
|
||||||
switch (dateFilter) {
|
switch (dateFilter) {
|
||||||
case 'today':
|
case 'today':
|
||||||
return created.toDateString() === now.toDateString();
|
return created.toDateString() === now.toDateString();
|
||||||
case 'week':
|
case 'week': {
|
||||||
const aWeekAgo = new Date();
|
const aWeekAgo = new Date();
|
||||||
aWeekAgo.setDate(now.getDate() - 7);
|
aWeekAgo.setDate(now.getDate() - 7);
|
||||||
return created >= aWeekAgo;
|
return created >= aWeekAgo;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const dateA = new Date(a.created_at);
|
const aDate = new Date(a.notesList[a.notesList.length - 1].created_at);
|
||||||
const dateB = new Date(b.created_at);
|
const bDate = new Date(b.notesList[b.notesList.length - 1].created_at);
|
||||||
return sortOrder === 'latest' ? dateB - dateA : dateA - dateB;
|
return sortOrder === 'latest' ? bDate - aDate : aDate - bDate;
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -138,10 +219,15 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
<p>No data matches the filters</p>
|
<p>No data matches the filters</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredData.map(user => (
|
filteredData.map(user => {
|
||||||
<div key={user.id} className={styles.card}>
|
const parsed = parseContactInfo(user.contact_info);
|
||||||
|
const icon = parsed.prefix ? PREFIX_ICON[parsed.prefix] : '📞';
|
||||||
|
const isValid = parsed.isValid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={user.contact_info} className={styles.card}>
|
||||||
<div className={styles.cardHeader}>
|
<div className={styles.cardHeader}>
|
||||||
<h3 className={styles.userName}>{user.name}</h3>
|
<h3 className={styles.userName}>{user.name} DARI {icon}</h3>
|
||||||
<span className={styles.statusBadge}>
|
<span className={styles.statusBadge}>
|
||||||
{user.issuccess ? (
|
{user.issuccess ? (
|
||||||
<span className={styles.badgeSuccess}>✓ Followed Up</span>
|
<span className={styles.badgeSuccess}>✓ Followed Up</span>
|
||||||
@@ -154,17 +240,42 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.cardContent}>
|
<div className={styles.cardContent}>
|
||||||
<p className={styles.notes}>{user.notes}</p>
|
<ul className={styles.notesList}>
|
||||||
<div className={styles.contactInfo}>
|
{user.notesList.map((entry, index) => (
|
||||||
<span className={styles.contactLabel}>Contact:</span>
|
<li key={index}>
|
||||||
<span className={styles.contactValue}>{user.contact_info}</span>
|
<strong>{new Date(entry.created_at).toLocaleString('id-ID', {
|
||||||
</div>
|
|
||||||
<div className={styles.dateInfo}>
|
|
||||||
{new Date(user.created_at).toLocaleString('id-ID', {
|
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
timeZone: 'Asia/Jakarta'
|
timeZone: 'Asia/Jakarta'
|
||||||
})}
|
})}:</strong> {entry.note}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className={styles.contactInfo}>
|
||||||
|
<span className={styles.contactLabel}>Contact:</span>
|
||||||
|
<span className={styles.contactValue}>
|
||||||
|
<span
|
||||||
|
className={styles.contactPrefix}
|
||||||
|
title={
|
||||||
|
parsed.prefix === 'WGG' ? 'WhatsApp' :
|
||||||
|
parsed.prefix === 'TGG' ? 'Telegram' :
|
||||||
|
parsed.prefix === 'WEB' ? 'Website' :
|
||||||
|
parsed.prefix === 'IGG' ? 'Instagram' : 'Phone'
|
||||||
|
}
|
||||||
|
aria-label="contact-source"
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
{/* Tampilkan HANYA nomor telepon (valid Indonesia) bila ada prefix */}
|
||||||
|
{parsed.prefix
|
||||||
|
? (isValid ? parsed.display : parsed.display) // tetap tampilkan hasil normalisasi
|
||||||
|
: (parsed.display) /* tanpa prefix, tampilkan sesuai normalisasi */}
|
||||||
|
{!isValid && (
|
||||||
|
<span className={styles.invalidHint} title="Nomor tidak terdeteksi sebagai +62">
|
||||||
|
{' '}• cek format
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,7 +298,8 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* FollowUps.module.css */
|
/* FollowUps.module.css */
|
||||||
.container {
|
.container {
|
||||||
background-color: #f8fafc;
|
background-color: #f1f3f4;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e0e0e0;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterGroup {
|
.filterGroup {
|
||||||
@@ -79,37 +79,38 @@
|
|||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
gap: 20px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: white;
|
background: #f8f9fa;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #dee2e6;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
.card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
border-color: #cbd5e1;
|
border-color: #adb5bd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardHeader {
|
.cardHeader {
|
||||||
padding: 20px 20px 12px;
|
padding: 18px 18px 12px;
|
||||||
border-bottom: 1px solid #f1f5f9;
|
border-bottom: 1px solid #e9ecef;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.userName {
|
.userName {
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1f2937;
|
color: #212529;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -119,42 +120,71 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badgeSuccess {
|
.badgeSuccess {
|
||||||
background: #dcfce7;
|
background: #d1e7dd;
|
||||||
color: #166534;
|
color: #0a3622;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
border: 1px solid #badbcc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badgeProgress {
|
.badgeProgress {
|
||||||
background: #fef3c7;
|
background: #fff3cd;
|
||||||
color: #92400e;
|
color: #664d03;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
border: 1px solid #ffda6a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badgePending {
|
.badgePending {
|
||||||
background: #f3f4f6;
|
background: #e2e3e5;
|
||||||
color: #6b7280;
|
color: #41464b;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
border: 1px solid #c4c8cc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardContent {
|
.cardContent {
|
||||||
padding: 12px 20px 20px;
|
padding: 16px 18px;
|
||||||
|
background: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notes {
|
.notesList {
|
||||||
color: #4b5563;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0 0 16px 0;
|
margin: 0 0 16px 0;
|
||||||
word-break: break-word;
|
padding: 12px;
|
||||||
|
list-style-type: none;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #212529;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notesList li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid #f8f9fa;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notesList li:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notesList li strong {
|
||||||
|
color: #6c757d;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contactInfo {
|
.contactInfo {
|
||||||
@@ -166,37 +196,33 @@
|
|||||||
|
|
||||||
.contactLabel {
|
.contactLabel {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #6b7280;
|
color: #6c757d;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contactValue {
|
.contactValue {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: #374151;
|
color: #495057;
|
||||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
}
|
background: #ffffff;
|
||||||
|
padding: 4px 6px;
|
||||||
.dateInfo {
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
border: 1px solid #e9ecef;
|
||||||
color: #9ca3af;
|
display: inline-block;
|
||||||
padding: 8px 12px;
|
|
||||||
background: #f9fafb;
|
|
||||||
border-radius: 6px;
|
|
||||||
border-left: 3px solid #e5e7eb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardActions {
|
.cardActions {
|
||||||
padding: 16px 20px;
|
padding: 14px 18px;
|
||||||
border-top: 1px solid #f1f5f9;
|
border-top: 1px solid #e9ecef;
|
||||||
background: #fafbfc;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionBtn {
|
.actionBtn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 16px;
|
padding: 10px 16px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
@@ -209,27 +235,32 @@
|
|||||||
.btnPrimary {
|
.btnPrimary {
|
||||||
background: #25d366;
|
background: #25d366;
|
||||||
color: white;
|
color: white;
|
||||||
|
border: 1px solid #25d366;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnPrimary:hover {
|
.btnPrimary:hover {
|
||||||
background: #1da851;
|
background: #1da851;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(37, 211, 102, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnComplete {
|
.btnComplete {
|
||||||
background: #3b82f6;
|
background: #0d6efd;
|
||||||
color: white;
|
color: white;
|
||||||
|
border: 1px solid #0d6efd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnComplete:hover {
|
.btnComplete:hover {
|
||||||
background: #2563eb;
|
background: #0b5ed7;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(13, 110, 253, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnSuccess {
|
.btnSuccess {
|
||||||
background: #10b981;
|
background: #198754;
|
||||||
color: white;
|
color: white;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
border: 1px solid #198754;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
@@ -238,16 +269,21 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyIcon {
|
.emptyIcon {
|
||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyState p {
|
.emptyState p {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
color: #6c757d;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design */
|
||||||
@@ -272,27 +308,31 @@
|
|||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardHeader {
|
.cardHeader {
|
||||||
padding: 16px 16px 10px;
|
padding: 14px 14px 10px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardContent {
|
.cardContent {
|
||||||
padding: 10px 16px 16px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardActions {
|
.cardActions {
|
||||||
padding: 12px 16px;
|
padding: 10px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionBtn {
|
.actionBtn {
|
||||||
padding: 10px 14px;
|
padding: 9px 14px;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,16 +341,21 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.userName {
|
.grid {
|
||||||
font-size: 16px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notes {
|
.userName {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notesList {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusBadge span {
|
.statusBadge span {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ const ProfileTab = () => {
|
|||||||
const [profileTemp, setProfileTemp] = useState({});
|
const [profileTemp, setProfileTemp] = useState({});
|
||||||
|
|
||||||
const licenses = [
|
const licenses = [
|
||||||
{ id: 1, type: "Current Subscription", number: "DRML-2025-AI001", validUntil: "June 30 2025" },
|
{ id: 1, type: "MONTHLY SUBSCRIPTION", number: "DRML-2025-AI001", validUntil: "Aug 31 2025" },
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,7 +146,7 @@ const ProfileTab = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<img src={profile?.image || '/no-brand.jpg'} alt="Bot Avatar" />
|
<img src={profile?.image || '/dermalounge.jpg'} alt="Bot Avatar" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.h1}>Dermalounge AI Admin Profile</h1>
|
<h1 className={styles.h1}>Dermalounge AI Admin Profile</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,9 +218,9 @@ const ProfileTab = () => {
|
|||||||
<div className={styles.licenseCards}>
|
<div className={styles.licenseCards}>
|
||||||
{licenses.map((item) => (
|
{licenses.map((item) => (
|
||||||
<div className={styles.licenseCard} key={item.id}>
|
<div className={styles.licenseCard} key={item.id}>
|
||||||
<p><strong>{item.type}</strong></p>
|
<p><strong>CURRENT PLAN</strong></p>
|
||||||
<p>{item.number}</p>
|
<p>{item.number}</p>
|
||||||
<p><strong>Free License </strong>Valid until: {item.validUntil}</p>
|
<p><strong style={{fontSize: '12px'}}>{item.type} </strong>Valid until: {item.validUntil}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user