This commit is contained in:
Vassshhh
2025-07-02 17:32:13 +07:00
parent ac212226b3
commit fe7d67a2d8
12 changed files with 1094 additions and 234 deletions

View File

@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
import Modal from './Modal'; import Modal from './Modal';
import Conversations from './Conversations'; import Conversations from './Conversations';
import DiscussedTopics from './DiscussedTopics'; import DiscussedTopics from './DiscussedTopics';
import StatCard from './StatCard'
import FollowUps from './FollowUps'; import FollowUps from './FollowUps';
@@ -36,14 +37,60 @@ const Dashboard = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleFiles = (files) => { const handleFiles = async (files) => {
const newFiles = files.filter(file => { const filteredFiles = [];
// Hindari duplikat berdasarkan nama (atau bisa pakai hash/md5 jika perlu)
return !selectedFiles.some(f => f.name === file.name); for (const file of files) {
const lowerName = file.name.toLowerCase();
const nameWithoutExt = lowerName.replace(/\.[^/.]+$/, '');
// 1⃣ Cegah duplikat dari file yang sudah dipilih sebelumnya
const alreadySelected = selectedFiles.some(f =>
f.name.toLowerCase() === file.name.toLowerCase()
);
if (alreadySelected) continue;
// 2⃣ Cari file server yang mirip (berisi / mengandung)
const similarFile = fileList.find(f => {
const serverName = f.json.Key.toLowerCase();
const serverNameWithoutExt = serverName.replace(/\.[^/.]+$/, '');
return (
serverName.includes(lowerName) ||
lowerName.includes(serverName) ||
serverNameWithoutExt.includes(nameWithoutExt) ||
nameWithoutExt.includes(serverNameWithoutExt)
);
}); });
setSelectedFiles((prev) => [...prev, ...newFiles]); if (similarFile) {
}; const confirmOverwrite = window.confirm(
`File "${file.name}" mirip atau mengandung "${similarFile.json.Key}" di server.\nIngin menimpa file tersebut?`
);
if (confirmOverwrite) {
// Ganti nama agar ditimpa
Object.defineProperty(file, 'name', {
writable: true,
value: similarFile.json.Key,
});
filteredFiles.push(file); // tetap tambahkan
} else {
// Tidak ditimpa, tetap pakai nama asli
filteredFiles.push(file); // tambahkan tanpa modifikasi nama
}
} else {
// Tidak ada kemiripan, langsung tambahkan
filteredFiles.push(file);
}
}
if (filteredFiles.length > 0) {
setSelectedFiles(prev => [...prev, ...filteredFiles]);
}
};
@@ -364,89 +411,89 @@ const Dashboard = () => {
} }
} }
}; };
const handleBatchUpload = async () => { const handleBatchUpload = async () => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const newFiles = []; const newFiles = [];
for (const file of selectedFiles) { for (const file of selectedFiles) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const response = await fetch('https://bot.kediritechnopark.com/webhook/files/upload', { const response = await fetch('https://bot.kediritechnopark.com/webhook/files/upload', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
}, },
body: formData body: formData
});
if (response.ok) {
newFiles.push({
json: {
Key: file.name,
LastModified: new Date().toISOString(),
Size: file.size,
StorageClass: 'STANDARD'
}
}); });
} else {
console.error(`Upload gagal untuk file ${file.name}`);
}
}
// ✅ Set fileList sekaligus
setFileList((prev) => [...prev, ...newFiles]);
alert('Upload selesai');
setSelectedFiles([]);
};
const handleBatchDelete = async () => {
if (!window.confirm(`Yakin ingin menghapus ${selectedKeys.length} file?`)) return;
const token = localStorage.getItem('token');
const successKeys = [];
const failedKeys = [];
for (const key of selectedKeys) {
try {
const response = await fetch(
`https://bot.kediritechnopark.com/webhook/files/delete?key=${encodeURIComponent(key)}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
}
);
if (response.ok) { if (response.ok) {
successKeys.push(key); newFiles.push({
json: {
Key: file.name,
LastModified: new Date().toISOString(),
Size: file.size,
StorageClass: 'STANDARD'
}
});
} else { } else {
console.error(`Upload gagal untuk file ${file.name}`);
}
}
// ✅ Set fileList sekaligus
setFileList((prev) => [...prev, ...newFiles]);
alert('Upload selesai');
setSelectedFiles([]);
};
const handleBatchDelete = async () => {
if (!window.confirm(`Yakin ingin menghapus ${selectedKeys.length} file?`)) return;
const token = localStorage.getItem('token');
const successKeys = [];
const failedKeys = [];
for (const key of selectedKeys) {
try {
const response = await fetch(
`https://bot.kediritechnopark.com/webhook/files/delete?key=${encodeURIComponent(key)}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
}
);
if (response.ok) {
successKeys.push(key);
} else {
failedKeys.push(key);
}
} catch (err) {
console.error(`Gagal menghapus ${key}`, err);
failedKeys.push(key); failedKeys.push(key);
} }
} catch (err) {
console.error(`Gagal menghapus ${key}`, err);
failedKeys.push(key);
} }
}
// ✅ Update fileList sekaligus // ✅ Update fileList sekaligus
setFileList((prev) => setFileList((prev) =>
prev.filter((file) => !successKeys.includes(file.json.Key)) prev.filter((file) => !successKeys.includes(file.json.Key))
); );
// ✅ Kosongkan selected // ✅ Kosongkan selected
setSelectedKeys([]); setSelectedKeys([]);
// ✅ Beri feedback ke user // ✅ Beri feedback ke user
if (failedKeys.length === 0) { if (failedKeys.length === 0) {
alert('File berhasil dihapus.'); alert('File berhasil dihapus.');
} else { } else {
alert(`Sebagian gagal dihapus:\n${failedKeys.join('\n')}`); alert(`Sebagian gagal dihapus:\n${failedKeys.join('\n')}`);
} }
}; };
// ⬇️ Jika masih loading, tampilkan full white screen // ⬇️ Jika masih loading, tampilkan full white screen
@@ -491,10 +538,7 @@ const handleBatchDelete = async () => {
<h2>{stats.botMessages}</h2> <h2>{stats.botMessages}</h2>
<p>AI RESPONSE</p> <p>AI RESPONSE</p>
</div> </div>
<div className={styles.statCard} onClick={() => setModalContent(<FollowUps data={followUps} />)}> <StatCard followUps={followUps} setModalContent={setModalContent} />
<h2>{followUps.length}</h2>
<p>BOOKING REQUEST</p>
</div>
<div className={styles.statCard} onClick={openTopicsModal}> <div className={styles.statCard} onClick={openTopicsModal}>
<h2 style={{ fontSize: '17px' }}>{discussedTopics[0]?.topic}</h2> <h2 style={{ fontSize: '17px' }}>{discussedTopics[0]?.topic}</h2>
<p>Top topic</p> <p>Top topic</p>
@@ -581,33 +625,33 @@ const handleBatchDelete = async () => {
<p className={styles.mobileText} onClick={() => document.getElementById("fileInput").click()}>Click to upload</p> <p className={styles.mobileText} onClick={() => document.getElementById("fileInput").click()}>Click to upload</p>
<div> <div>
{selectedFiles.length > 0 &&
selectedFiles.map((file, index) => (
<div>
<div key={index} className={styles.fileInfo}>
<strong>{file.name}</strong>
</div>
<div
className={styles.fileInfoClose}
onClick={() =>
setSelectedFiles((prev) => prev.filter((_, i) => i !== index))
}
>
X
</div>
</div>
))}
{selectedFiles.length > 0 && {selectedFiles.length > 0 &&
<div> selectedFiles.map((file, index) => (
<div onClick={()=>handleBatchUpload()} className={styles.fileUpload}> <div key={index}>
<strong>Upload</strong> <div key={index} className={styles.fileInfo}>
</div> <strong>{file.name}</strong>
</div> </div>
} <div
className={styles.fileInfoClose}
onClick={() =>
setSelectedFiles((prev) => prev.filter((_, i) => i !== index))
}
>
X
</div>
</div>
))}
{selectedFiles.length > 0 &&
<div>
<div onClick={() => handleBatchUpload()} className={styles.fileUpload}>
<strong>Upload</strong>
</div>
</div> </div>
}
</div>
<input <input
id="fileInput" id="fileInput"
type="file" type="file"
@@ -626,7 +670,7 @@ const handleBatchDelete = async () => {
<div className={styles.footer}> <div className={styles.footer}>
&copy; 2025 Kediri Technopark &copy; 2025 Dermalounge
</div> </div>
{modalContent && <Modal onClose={() => setModalContent(null)}>{modalContent}</Modal>} {modalContent && <Modal onClose={() => setModalContent(null)}>{modalContent}</Modal>}

View File

@@ -137,7 +137,7 @@
.fileUpload { .fileUpload {
margin-top: 16px; margin-top: 16px;
font-size: 14px; font-size: 14px;
color: white; color: #333;
background: #2bb438; background: #2bb438;
padding: 8px 12px; padding: 8px 12px;
border-radius: 8px; border-radius: 8px;

View File

@@ -1,16 +1,37 @@
// DiscussedTopics.js // DiscussedTopics.js
import React from 'react'; import React from 'react';
import styles from './DiscussedTopics.module.css';
const DiscussedTopics = ({ topics }) => { const DiscussedTopics = ({ topics }) => {
return ( return (
<div> <div className={styles.container}>
<h2>Top Topic</h2> <div className={styles.header}>
<ul> <h2 className={styles.title}>Top Topic</h2>
{topics.map((topic, idx) => ( <div className={styles.resultCount}>
<li key={idx}><strong>{topic.topic}</strong> - {topic.count} x</li> {topics.length} topik
))} </div>
</ul> </div>
<div className={styles.grid}>
{topics.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>💬</div>
<p>Discussed Topic Is Empty</p>
</div>
) : (
topics.map((topic, idx) => (
<div key={idx} className={styles.card}>
<div className={styles.cardContent}>
<h3 className={styles.topicName}>{topic.topic}</h3>
<div className={styles.countBadge}>
<span className={styles.countValue}>{topic.count}</span>
<span className={styles.countLabel}>times</span>
</div>
</div>
</div>
))
)}
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,187 @@
/* DiscussedTopics.module.css */
.container {
background-color: #f8fafc;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* Header */
.header {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.title {
font-size: 24px;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.resultCount {
font-size: 13px;
color: #6b7280;
font-weight: 500;
padding: 8px 12px;
background: #f1f5f9;
border-radius: 6px;
display: inline-block;
}
/* Cards Grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.card {
background: white;
border-radius: 12px;
border: 1px solid #e2e8f0;
overflow: hidden;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-color: #cbd5e1;
}
.cardContent {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.topicName {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin: 0;
line-height: 1.4;
word-break: break-word;
}
.countBadge {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 12px 16px;
background: #f8fafc;
border-radius: 8px;
border-left: 3px solid #0b7366;
}
.countValue {
font-size: 24px;
font-weight: 700;
color: #0b7366;
line-height: 1;
}
.countLabel {
font-size: 12px;
color: #6b7280;
font-weight: 500;
}
/* Empty State */
.emptyState {
grid-column: 1 / -1;
text-align: center;
padding: 60px 20px;
color: #6b7280;
}
.emptyIcon {
font-size: 48px;
margin-bottom: 16px;
}
.emptyState p {
font-size: 16px;
margin: 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
padding: 16px;
}
.header {
padding: 16px;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.title {
font-size: 20px;
}
.grid {
grid-template-columns: 1fr;
gap: 16px;
}
.cardContent {
padding: 16px;
}
.topicName {
font-size: 16px;
}
.countValue {
font-size: 20px;
}
}
@media (max-width: 480px) {
.container {
padding: 12px;
}
.header {
padding: 12px;
}
.title {
font-size: 18px;
}
.cardContent {
padding: 12px;
}
.topicName {
font-size: 15px;
}
.countBadge {
padding: 10px 12px;
}
.countValue {
font-size: 18px;
}
.countLabel {
font-size: 11px;
}
}

View File

@@ -1,36 +1,194 @@
import React from 'react'; // FollowUps.js
import React, { useState } from 'react';
import styles from './FollowUps.module.css'; import styles from './FollowUps.module.css';
const FollowUps = ({ data }) => { const FollowUps = ({ data: initialData }) => {
const [data, setData] = useState(initialData);
const [statusFilter, setStatusFilter] = useState('all');
const [dateFilter, setDateFilter] = useState('all');
const [sortOrder, setSortOrder] = useState('latest');
const handleFollowUp = async (e, user) => {
e.preventDefault();
try {
await fetch('https://bot.kediritechnopark.com/webhook/set-follow-up', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: user.id,
isfollowup: user.isfollowup ? 'success' : 'followup'
})
});
if (user.isfollowup) {
setData(prev =>
prev.map(u =>
u.id === user.id ? { ...u, isfollowup: false, issuccess: true } : u
)
);
} else {
setData(prev =>
prev.map(u =>
u.id === user.id ? { ...u, isfollowup: true } : u
)
);
}
if (!user.isfollowup) {
window.open(`https://api.whatsapp.com/send?phone=${user.contact_info}`, '_blank');
}
} catch (error) {
console.error('Failed to set follow-up:', error);
alert('Failed to send follow-up status.');
}
};
// Filter & Sort
const now = new Date();
const filteredData = data
.filter(user => {
switch (statusFilter) {
case 'pending':
return !user.isfollowup && !user.issuccess;
case 'inProgress':
return user.isfollowup && !user.issuccess;
case 'success':
return user.issuccess;
default:
return true;
}
})
.filter(user => {
const created = new Date(user.created_at);
switch (dateFilter) {
case 'today':
return created.toDateString() === now.toDateString();
case 'week':
const aWeekAgo = new Date();
aWeekAgo.setDate(now.getDate() - 7);
return created >= aWeekAgo;
default:
return true;
}
})
.sort((a, b) => {
const dateA = new Date(a.created_at);
const dateB = new Date(b.created_at);
return sortOrder === 'latest' ? dateB - dateA : dateA - dateB;
});
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.grid}> {/* Filter Controls */}
{data.map(user => ( <div className={styles.filterSection}>
<div key={user.id} className={styles.card}> <div className={styles.filterGroup}>
<div className={styles.header}> <div className={styles.filterItem}>
<h3>{user.name}</h3> <label className={styles.filterLabel}>Status</label>
<span className={styles.date}> <select
{new Date(user.created_at).toLocaleString('id-ID', { className={styles.filterSelect}
dateStyle: 'medium', value={statusFilter}
timeStyle: 'short', onChange={e => setStatusFilter(e.target.value)}
timeZone: 'Asia/Jakarta' >
})} <option value="all">All Statuses</option>
</span> <option value="pending">Pending</option>
</div> <option value="inProgress">In Progress</option>
<p className={styles.notes}>{user.notes}</p> <option value="success">Followed Up</option>
<div className={styles.footer}> </select>
<span className={styles.contact}>{user.contact_info}</span>
<a
className={styles.chatBtn}
href={`https://api.whatsapp.com/send?phone=${user.contact_info}`}
target="_blank"
rel="noopener noreferrer"
>
Chat WhatsApp
</a>
</div>
</div> </div>
))}
<div className={styles.filterItem}>
<label className={styles.filterLabel}>Period</label>
<select
className={styles.filterSelect}
value={dateFilter}
onChange={e => setDateFilter(e.target.value)}
>
<option value="all">All Time</option>
<option value="today">Today</option>
<option value="week">Last 7 Days</option>
</select>
</div>
<div className={styles.filterItem}>
<label className={styles.filterLabel}>Sort By</label>
<select
className={styles.filterSelect}
value={sortOrder}
onChange={e => setSortOrder(e.target.value)}
>
<option value="latest">Latest</option>
<option value="oldest">Oldest</option>
</select>
</div>
</div>
<div className={styles.resultCount}>
{filteredData.length} of {data.length} records
</div>
</div>
{/* Cards Grid */}
<div className={styles.grid}>
{filteredData.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>📋</div>
<p>No data matches the filters</p>
</div>
) : (
filteredData.map(user => (
<div key={user.id} className={styles.card}>
<div className={styles.cardHeader}>
<h3 className={styles.userName}>{user.name}</h3>
<span className={styles.statusBadge}>
{user.issuccess ? (
<span className={styles.badgeSuccess}> Followed Up</span>
) : user.isfollowup ? (
<span className={styles.badgeProgress}> In Progress</span>
) : (
<span className={styles.badgePending}> Pending</span>
)}
</span>
</div>
<div className={styles.cardContent}>
<p className={styles.notes}>{user.notes}</p>
<div className={styles.contactInfo}>
<span className={styles.contactLabel}>Contact:</span>
<span className={styles.contactValue}>{user.contact_info}</span>
</div>
<div className={styles.dateInfo}>
{new Date(user.created_at).toLocaleString('id-ID', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'Asia/Jakarta'
})}
</div>
</div>
<div className={styles.cardActions}>
<button
className={`${styles.actionBtn} ${
user.issuccess
? styles.btnSuccess
: user.isfollowup
? styles.btnComplete
: styles.btnPrimary
}`}
onClick={(e) => handleFollowUp(e, user)}
>
{user.issuccess
? '✓ Follow-up Success'
: user.isfollowup
? '✓ Mark as Complete'
: '💬 Chat on WhatsApp'}
</button>
</div>
</div>
))
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,110 +1,316 @@
/* FollowUps.module.css */
.container { .container {
background-color: #f7f9fa; background-color: #f8fafc;
font-family: 'Amazon Ember', sans-serif; min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
} }
.title { /* Filter Section */
font-size: 22px; .filterSection {
font-weight: 600; background: white;
color: #16191f; border-radius: 12px;
margin-bottom: 20px; padding: 20px;
text-align: center; margin-bottom: 24px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} }
.filterGroup {
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: end;
margin-bottom: 12px;
}
.filterItem {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 160px;
}
.filterLabel {
font-size: 14px;
font-weight: 500;
color: #374151;
margin: 0;
}
.filterSelect {
padding: 10px 12px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
background: white;
color: #374151;
cursor: pointer;
transition: all 0.2s ease;
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
background-position: right 8px center;
background-repeat: no-repeat;
background-size: 16px;
padding-right: 36px;
}
.filterSelect:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.filterSelect:hover {
border-color: #cbd5e1;
}
.resultCount {
font-size: 13px;
color: #6b7280;
font-weight: 500;
padding: 8px 12px;
background: #f1f5f9;
border-radius: 6px;
display: inline-block;
}
/* Cards Grid */
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px; gap: 20px;
} }
.card { .card {
background-color: #ffffff; background: white;
border: 1px solid #d1d5db; border-radius: 12px;
border-radius: 8px; border: 1px solid #e2e8f0;
padding: 16px; overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); transition: all 0.2s ease;
display: flex; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
flex-direction: column;
justify-content: space-between;
transition: box-shadow 0.2s ease;
} }
.card:hover { .card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-color: #cbd5e1;
} }
.header { .cardHeader {
padding: 20px 20px 12px;
border-bottom: 1px solid #f1f5f9;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
flex-wrap: wrap; gap: 12px;
gap: 4px;
} }
.header h3 { .userName {
font-size: 18px; font-size: 18px;
font-weight: 600;
color: #1f2937;
margin: 0; margin: 0;
color: #232f3e; flex: 1;
} }
.date { .statusBadge {
flex-shrink: 0;
}
.badgeSuccess {
background: #dcfce7;
color: #166534;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px; font-size: 12px;
font-weight: 500;
}
.badgeProgress {
background: #fef3c7;
color: #92400e;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.badgePending {
background: #f3f4f6;
color: #6b7280; color: #6b7280;
white-space: nowrap; padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.cardContent {
padding: 12px 20px 20px;
} }
.notes { .notes {
margin: 12px 0; color: #4b5563;
color: #374151;
font-size: 14px; font-size: 14px;
line-height: 1.5;
margin: 0 0 16px 0;
word-break: break-word;
} }
.footer { .contactInfo {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; gap: 4px;
flex-wrap: wrap; margin-bottom: 12px;
gap: 8px;
margin-top: 8px;
} }
.contact { .contactLabel {
font-size: 13px; font-size: 12px;
color: #374151; color: #6b7280;
word-break: break-all;
}
.chatBtn {
background-color: #25d366;
color: #ffffff;
text-decoration: none;
padding: 6px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 500; font-weight: 500;
transition: background-color 0.2s ease;
white-space: nowrap;
} }
.chatBtn:hover { .contactValue {
background-color: #1da851; font-size: 14px;
color: #374151;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
.dateInfo {
font-size: 12px;
color: #9ca3af;
padding: 8px 12px;
background: #f9fafb;
border-radius: 6px;
border-left: 3px solid #e5e7eb;
}
.cardActions {
padding: 16px 20px;
border-top: 1px solid #f1f5f9;
background: #fafbfc;
}
.actionBtn {
width: 100%;
padding: 12px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btnPrimary {
background: #25d366;
color: white;
}
.btnPrimary:hover {
background: #1da851;
transform: translateY(-1px);
}
.btnComplete {
background: #3b82f6;
color: white;
}
.btnComplete:hover {
background: #2563eb;
transform: translateY(-1px);
}
.btnSuccess {
background: #10b981;
color: white;
cursor: default;
}
/* Empty State */
.emptyState {
grid-column: 1 / -1;
text-align: center;
padding: 60px 20px;
color: #6b7280;
}
.emptyIcon {
font-size: 48px;
margin-bottom: 16px;
}
.emptyState p {
font-size: 16px;
margin: 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
padding: 16px;
}
.filterSection {
padding: 16px;
}
.filterGroup {
flex-direction: column;
gap: 16px;
}
.filterItem {
min-width: auto;
width: 100%;
}
.grid {
grid-template-columns: 1fr;
gap: 16px;
}
.cardHeader {
padding: 16px 16px 10px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.cardContent {
padding: 10px 16px 16px;
}
.cardActions {
padding: 12px 16px;
}
.actionBtn {
padding: 10px 14px;
font-size: 13px;
}
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.title { .container {
font-size: 18px;
}
.card {
padding: 12px; padding: 12px;
} }
.userName {
font-size: 16px;
}
.notes { .notes {
font-size: 13px; font-size: 13px;
} }
.chatBtn { .statusBadge span {
padding: 6px 10px; font-size: 11px;
font-size: 12px; padding: 3px 6px;
} }
} }

View File

@@ -69,7 +69,7 @@ const Login = () => {
</button> </button>
</form> </form>
<div className={styles.footer}> <div className={styles.footer}>
&copy; 2025 Kediri Technopark &copy; 2025 Dermalounge
</div> </div>
</div> </div>
</div> </div>

View File

@@ -16,10 +16,9 @@
background: white; background: white;
border-radius: 10px; border-radius: 10px;
max-width: 700px; max-width: 700px;
width: 70%; width: 85%;
max-height: 80vh; max-height: 80vh;
overflow-y: auto; overflow-y: auto;
padding: 20px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
position: relative; position: relative;
} }

View File

@@ -5,19 +5,28 @@ export default function NotificationPrompt({ onAllow, onDismiss }) {
return ( return (
<div className={styles.backdrop}> <div className={styles.backdrop}>
<div className={styles.modal}> <div className={styles.modal}>
<h2 className={styles.title}>Enable Notifications</h2> <div className={styles.iconWrapper}>
<p className={styles.description}> <div className={styles.notificationIcon}>🔔</div>
Stay up to date with important updates and alerts. Enable push notifications to never miss a thing. </div>
</p>
<div className={styles.content}>
<h2 className={styles.title}>Aktifkan Notifikasi</h2>
<p className={styles.description}>
Tetap terhubung dengan update penting dan peringatan terbaru.
Aktifkan notifikasi push agar tidak ketinggalan informasi penting.
</p>
</div>
<div className={styles.actions}> <div className={styles.actions}>
<button onClick={onAllow} className={styles.primaryButton}> <button onClick={onAllow} className={styles.primaryButton}>
Enable Notifications <span className={styles.buttonIcon}></span>
Aktifkan Notifikasi
</button> </button>
<button onClick={onDismiss} className={styles.secondaryButton}> <button onClick={onDismiss} className={styles.secondaryButton}>
Maybe Later Nanti Saja
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,35 +1,67 @@
/* NotificationPrompt.module.css */
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
background-color: rgba(0, 0, 0, 0.45); background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 9999; z-index: 9999;
animation: fadeIn 0.2s ease-out;
} }
.modal { .modal {
background-color: #fff; background-color: #fff;
border-radius: 8px; border-radius: 12px;
padding: 32px; padding: 32px;
max-width: 420px; max-width: 420px;
width: 90%; width: 90%;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15); box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
border: 1px solid #e2e8f0;
text-align: center; text-align: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
animation: slideUp 0.3s ease-out;
position: relative;
}
.iconWrapper {
margin-bottom: 24px;
display: flex;
justify-content: center;
}
.notificationIcon {
font-size: 48px;
background: #f1f5f9;
border-radius: 50%;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e2e8f0;
}
.content {
margin-bottom: 32px;
} }
.title { .title {
margin-bottom: 16px; margin-bottom: 16px;
font-size: 22px; font-size: 24px;
font-weight: 600; font-weight: 600;
color: #1f2937; color: #1f2937;
line-height: 1.3;
} }
.description { .description {
margin-bottom: 24px; margin: 0;
font-size: 16px; font-size: 16px;
color: #4b5563; color: #4b5563;
line-height: 1.5; line-height: 1.6;
max-width: 340px;
margin: 0 auto;
} }
.actions { .actions {
@@ -39,30 +71,129 @@
} }
.primaryButton { .primaryButton {
background-color: #0073bb; background: #3b82f6;
color: white; color: white;
font-weight: 500; font-weight: 500;
padding: 12px 20px; padding: 14px 24px;
border: none; border: none;
border-radius: 6px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; font-size: 15px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
} }
.primaryButton:hover { .primaryButton:hover {
background-color: #005a99; background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.primaryButton:active {
transform: translateY(0);
}
.buttonIcon {
font-size: 14px;
} }
.secondaryButton { .secondaryButton {
background-color: transparent; background-color: transparent;
color: #374151; color: #6b7280;
border: 1px solid #d1d5db; border: 2px solid #e2e8f0;
padding: 12px 20px; padding: 12px 24px;
border-radius: 6px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; font-size: 15px;
font-weight: 500;
transition: all 0.2s ease;
font-family: inherit;
} }
.secondaryButton:hover { .secondaryButton:hover {
background-color: #f3f4f6; background-color: #f8fafc;
border-color: #cbd5e1;
color: #374151;
transform: translateY(-1px);
} }
.secondaryButton:active {
transform: translateY(0);
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Responsive Design */
@media (max-width: 480px) {
.modal {
padding: 24px 20px;
margin: 16px;
width: calc(100% - 32px);
}
.iconWrapper {
margin-bottom: 20px;
}
.notificationIcon {
width: 64px;
height: 64px;
font-size: 32px;
}
.title {
font-size: 20px;
margin-bottom: 12px;
}
.description {
font-size: 15px;
}
.content {
margin-bottom: 24px;
}
.primaryButton,
.secondaryButton {
padding: 12px 20px;
font-size: 14px;
}
}
@media (max-width: 320px) {
.modal {
padding: 20px 16px;
}
.title {
font-size: 18px;
}
.description {
font-size: 14px;
}
}

48
src/StatCard.js Normal file
View File

@@ -0,0 +1,48 @@
import React, { useEffect, useState } from 'react';
import styles from './StatCard.module.css';
import FollowUps from './FollowUps';
const StatCard = ({ followUps, setModalContent }) => {
const [activeIndex, setActiveIndex] = useState(0); // 0 = booking request, 1 = sukses
const [direction, setDirection] = useState('right'); // for animation direction
const views = [
{
label: 'BOOKING REQUEST',
data: followUps.filter(u => !u.isfollowup && !u.issuccess),
},
{
label: 'FOLLOWED UP',
data: followUps.filter(u => u.issuccess),
}
];
// Swipe timer
useEffect(() => {
const interval = setInterval(() => {
setDirection(prev => (prev === 'right' ? 'left' : 'right'));
setActiveIndex(prev => 1 - prev);
}, 4000);
return () => clearInterval(interval);
}, []);
const handleClick = () => {
setModalContent(<FollowUps data={followUps} />);
};
return (
<div className={styles.statCard} onClick={handleClick}>
<div
key={activeIndex} // re-render to trigger animation
className={`${styles.cardContent} ${
direction === 'right' ? styles.slideInRight : styles.slideInLeft
}`}
>
<h2>{views[activeIndex].data.length}</h2>
<p>{views[activeIndex].label}</p>
</div>
</div>
);
};
export default StatCard;

57
src/StatCard.module.css Normal file
View File

@@ -0,0 +1,57 @@
.statCard {
overflow: hidden;
background: #ece5dd;
border-radius: 10px;
padding: 20px;
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
justify-content: space-around;
}
.statCard h2 {
margin: 0;
font-size: 28px;
color: #075e54;
}
.statCard p {
margin: 5px 0 0;
font-size: 14px;
}
.cardContent {
position: relative;
animation-duration: 0.6s;
animation-fill-mode: both;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.slideInRight {
animation-name: slideInRight;
}
.slideInLeft {
animation-name: slideInLeft;
}