customeragent-api / client /src /components /BulkUploadModal.jsx
anasraza526's picture
Clean deploy to Hugging Face
ac90985
import React, { useState, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Upload, FileText, Download, Loader, Check, AlertCircle, Trash2, PlayCircle, PauseCircle } from 'lucide-react';
import api from '../api/axiosConfig';
import toast from 'react-hot-toast';
const BulkUploadModal = ({ isOpen, onClose, websiteId, onSuccess }) => {
const [files, setFiles] = useState([]);
const [isDragging, setIsDragging] = useState(false);
const [uploadStatus, setUploadStatus] = useState({});
const [isProcessing, setIsProcessing] = useState(false);
const [previewData, setPreviewData] = useState(null); // { fileName: string, data: array }
const fileInputRef = useRef(null);
const handleDragOver = useCallback((e) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e) => {
e.preventDefault();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files).filter(
file => file.name.endsWith('.csv') || file.name.endsWith('.json')
);
if (droppedFiles.length > 0) {
addFilesToQueue(droppedFiles);
} else {
toast.error('Please upload CSV or JSON files only');
}
}, []);
const handleFileSelect = (e) => {
const selectedFiles = Array.from(e.target.files);
addFilesToQueue(selectedFiles);
};
const addFilesToQueue = (newFiles) => {
const fileObjects = newFiles.map((file, idx) => ({
id: Date.now() + idx,
file,
name: file.name,
size: file.size,
status: 'pending', // pending, uploading, success, error
progress: 0,
result: null
}));
setFiles(prev => [...prev, ...fileObjects]);
// Auto-preview first file if no preview exists
if (!previewData && fileObjects.length > 0) {
generatePreview(fileObjects[0]);
}
};
const generatePreview = async (fileObj) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
let data = [];
const content = e.target.result;
if (fileObj.name.endsWith('.json')) {
const json = JSON.parse(content);
data = Array.isArray(json) ? json : [json];
} else {
// Simple CSV parsing for preview
const lines = content.split('\n');
const headers = lines[0].split(',').map(h => h.trim().replace(/^"(.*)"$/, '$1'));
data = lines.slice(1).filter(l => l.trim()).map(line => {
const values = line.split(',').map(v => v.trim().replace(/^"(.*)"$/, '$1'));
const obj = {};
headers.forEach((header, i) => {
obj[header] = values[i];
});
return obj;
});
}
setPreviewData({
fileName: fileObj.name,
data: data.slice(0, 5) // Show first 5 for preview
});
} catch (err) {
console.error("Preview generation failed", err);
toast.error("Failed to generate preview for " + fileObj.name);
}
};
reader.readAsText(fileObj.file);
};
const removeFile = (fileId) => {
const remaining = files.filter(f => f.id !== fileId);
setFiles(remaining);
if (previewData && previewData.fileName === files.find(f => f.id === fileId)?.name) {
if (remaining.length > 0) {
generatePreview(remaining[0]);
} else {
setPreviewData(null);
}
}
setUploadStatus(prev => {
const newStatus = { ...prev };
delete newStatus[fileId];
return newStatus;
});
};
const uploadFile = async (fileObj) => {
const formData = new FormData();
formData.append('file', fileObj.file);
formData.append('website_id', websiteId);
try {
setFiles(prev => prev.map(f =>
f.id === fileObj.id ? { ...f, status: 'uploading', progress: 0 } : f
));
const response = await api.post('/faqs/bulk-upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setFiles(prev => prev.map(f =>
f.id === fileObj.id ? { ...f, progress: percentCompleted } : f
));
}
});
setFiles(prev => prev.map(f =>
f.id === fileObj.id
? { ...f, status: 'success', progress: 100, result: response.data }
: f
));
setUploadStatus(prev => ({
...prev,
[fileObj.id]: {
success: true,
data: response.data
}
}));
toast.success(`${fileObj.name}: ${response.data.successful} FAQs uploaded successfully`);
return true;
} catch (error) {
setFiles(prev => prev.map(f =>
f.id === fileObj.id
? { ...f, status: 'error', progress: 0 }
: f
));
setUploadStatus(prev => ({
...prev,
[fileObj.id]: {
success: false,
error: error.response?.data?.detail || 'Upload failed'
}
}));
toast.error(`${fileObj.name}: Upload failed`);
return false;
}
};
const processQueue = async () => {
setIsProcessing(true);
const pendingFiles = files.filter(f => f.status === 'pending');
for (const fileObj of pendingFiles) {
await uploadFile(fileObj);
}
setIsProcessing(false);
onSuccess();
};
const downloadSampleCSV = () => {
const csvContent = `question,answer,category,priority,is_active
"What are your operating hours?","We are open Monday to Friday, 9 AM to 5 PM.","General",5,true
"How can I contact support?","You can reach us at support@example.com or call (123) 456-7890.","Support",8,true
"What payment methods do you accept?","We accept all major credit cards, PayPal, and bank transfers.","Billing",7,true`;
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'faq_template.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
toast.success('Sample CSV template downloaded');
};
const downloadSampleJSON = () => {
const jsonContent = JSON.stringify([
{
question: "What are your operating hours?",
answer: "We are open Monday to Friday, 9 AM to 5 PM.",
category: "General",
priority: 5,
is_active: true
},
{
question: "How can I contact support?",
answer: "You can reach us at support@example.com or call (123) 456-7890.",
category: "Support",
priority: 8,
is_active: true
},
{
question: "What payment methods do you accept?",
answer: "We accept all major credit cards, PayPal, and bank transfers.",
category: "Billing",
priority: 7,
is_active: true
}
], null, 2);
const blob = new Blob([jsonContent], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'faq_template.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
toast.success('Sample JSON template downloaded');
};
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
const getStatusIcon = (status) => {
switch (status) {
case 'uploading':
return <Loader className="w-5 h-5 text-blue-500 animate-spin" />;
case 'success':
return <Check className="w-5 h-5 text-green-500" />;
case 'error':
return <AlertCircle className="w-5 h-5 text-red-500" />;
default:
return <FileText className="w-5 h-5 text-secondary-400" />;
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-secondary-900/50 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-white rounded-2xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col"
>
{/* Header */}
<div className="px-6 py-4 border-b border-secondary-100 flex justify-between items-center bg-gradient-to-r from-green-50 to-emerald-50">
<div>
<h3 className="text-xl font-bold text-secondary-900 flex items-center gap-2">
<Upload className="w-6 h-6 text-green-600" />
Bulk Upload FAQs
</h3>
<p className="text-sm text-secondary-600 mt-1">Upload multiple FAQs using CSV or JSON files</p>
</div>
<button
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 p-2 hover:bg-white rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Template Download Section */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<Download className="w-5 h-5 text-blue-600 mt-0.5" />
<div className="flex-1">
<h4 className="font-semibold text-blue-900">Download Sample Templates</h4>
<p className="text-sm text-blue-700 mt-1">Use these templates to format your FAQ data correctly</p>
<div className="flex gap-3 mt-3">
<button
onClick={downloadSampleCSV}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
>
<FileText className="w-4 h-4" />
CSV Template
</button>
<button
onClick={downloadSampleJSON}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium flex items-center gap-2"
>
<FileText className="w-4 h-4" />
JSON Template
</button>
</div>
</div>
</div>
</div>
{/* Dropzone */}
{!isProcessing && files.every(f => f.status === 'pending') && (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-all ${isDragging
? 'border-green-500 bg-green-50'
: 'border-secondary-300 hover:border-green-400 hover:bg-green-50/50'
}`}
>
<Upload className={`w-12 h-12 mx-auto mb-4 ${isDragging ? 'text-green-500' : 'text-secondary-400'}`} />
<p className="text-lg font-medium text-secondary-900 mb-1">
{isDragging ? 'Drop files here' : 'Drag & drop files here'}
</p>
<p className="text-sm text-secondary-500">or click to browse</p>
<p className="text-xs text-secondary-400 mt-2">Supports CSV and JSON files</p>
<input
ref={fileInputRef}
type="file"
accept=".csv,.json"
multiple
onChange={handleFileSelect}
className="hidden"
/>
</div>
)}
{/* Data Preview */}
{previewData && files.some(f => f.status === 'pending') && !isProcessing && (
<div className="bg-white border border-secondary-200 rounded-xl overflow-hidden shadow-sm">
<div className="px-4 py-3 bg-secondary-50 border-b border-secondary-100 flex justify-between items-center">
<h4 className="font-semibold text-secondary-900 flex items-center gap-2">
<FileText className="w-4 h-4 text-primary-500" />
Preview: {previewData.fileName} (First 5 rows)
</h4>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left text-secondary-500">
<thead className="text-xs text-secondary-700 uppercase bg-secondary-50">
<tr>
<th className="px-4 py-2">Question</th>
<th className="px-4 py-2">Answer</th>
<th className="px-4 py-2">Category</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{previewData.data.map((row, idx) => (
<tr key={idx} className="bg-white hover:bg-secondary-50/50 transition-colors">
<td className="px-4 py-2 font-medium text-secondary-900 max-w-xs truncate">{row.question}</td>
<td className="px-4 py-2 max-w-xs truncate">{row.answer}</td>
<td className="px-4 py-2">{row.category || 'General'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Upload Queue */}
{files.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-secondary-900">Upload Queue ({files.length})</h4>
{!isProcessing && (
<button
onClick={() => {
setFiles([]);
setPreviewData(null);
}}
className="text-sm text-red-600 hover:text-red-700 font-medium"
>
Clear All
</button>
)}
</div>
<div className="space-y-2 max-h-64 overflow-y-auto pr-2">
{files.map((fileObj) => (
<motion.div
key={fileObj.id}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`bg-white border rounded-lg p-4 transition-all ${fileObj.status === 'success' ? 'border-green-200 bg-green-50/20' :
fileObj.status === 'error' ? 'border-red-200 bg-red-50/20' :
'border-secondary-200 hover:shadow-md'
}`}
>
<div className="flex items-center gap-3">
{getStatusIcon(fileObj.status)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-secondary-900 truncate">{fileObj.name}</p>
<p className="text-xs text-secondary-500 whitespace-nowrap">{formatFileSize(fileObj.size)}</p>
</div>
{fileObj.status === 'uploading' && (
<div className="mt-2">
<div className="w-full bg-secondary-200 rounded-full h-1.5 overflow-hidden">
<div
className="bg-primary-600 h-1.5 transition-all duration-300"
style={{ width: `${fileObj.progress}%` }}
/>
</div>
<div className="flex justify-between items-center mt-1">
<p className="text-[10px] text-secondary-600 uppercase font-bold tracking-wider">Uploading...</p>
<p className="text-[10px] text-primary-600 font-bold">{fileObj.progress}%</p>
</div>
</div>
)}
{fileObj.status === 'success' && fileObj.result && (
<div className="mt-2 flex items-center gap-3 text-xs">
<span className="text-green-600 font-semibold bg-green-100 px-2 py-0.5 rounded-full">
✓ {fileObj.result.successful} Saved
</span>
{fileObj.result.failed > 0 && (
<span className="text-red-600 font-semibold bg-red-100 px-2 py-0.5 rounded-full">
⚠ {fileObj.result.failed} Failed
</span>
)}
</div>
)}
{fileObj.status === 'error' && uploadStatus[fileObj.id]?.error && (
<p className="mt-1 text-xs text-red-600 font-medium">
✗ {uploadStatus[fileObj.id].error}
</p>
)}
</div>
{fileObj.status === 'pending' && !isProcessing && (
<button
onClick={(e) => {
e.stopPropagation();
removeFile(fileObj.id);
}}
className="p-2 text-secondary-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors flex-shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</motion.div>
))}
</div>
</div>
)}
{/* Overall Summary if all completed */}
{!isProcessing && files.length > 0 && files.every(f => f.status !== 'pending') && (
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-secondary-900 text-white rounded-xl p-6 shadow-xl"
>
<h4 className="font-bold text-lg mb-4 flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-400" />
Batch Processing Complete
</h4>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="bg-white/10 rounded-lg p-3 backdrop-blur-sm">
<p className="text-secondary-400 text-xs uppercase font-bold tracking-wider">Total Rows</p>
<p className="text-2xl font-black text-white">
{files.reduce((acc, f) => acc + (f.result?.total || 0), 0)}
</p>
</div>
<div className="bg-green-500/20 rounded-lg p-3 backdrop-blur-sm border border-green-500/30">
<p className="text-green-400 text-xs uppercase font-bold tracking-wider">Successful</p>
<p className="text-2xl font-black text-green-400">
{files.reduce((acc, f) => acc + (f.result?.successful || 0), 0)}
</p>
</div>
<div className="bg-red-500/20 rounded-lg p-3 backdrop-blur-sm border border-red-500/30">
<p className="text-red-400 text-xs uppercase font-bold tracking-wider">Failed</p>
<p className="text-2xl font-black text-red-400">
{files.reduce((acc, f) => acc + (f.result?.failed || 0), 0)}
</p>
</div>
</div>
</motion.div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-secondary-100 bg-secondary-50 flex justify-between items-center">
<div className="text-sm text-secondary-600 font-medium">
{files.length > 0 && (
<div className="flex items-center gap-2">
<div className="w-32 bg-secondary-200 rounded-full h-1.5 overflow-hidden">
<div
className="bg-green-500 h-1.5 transition-all"
style={{ width: `${(files.filter(f => f.status === 'success' || f.status === 'error').length / files.length) * 100}%` }}
/>
</div>
<span>
{files.filter(f => f.status === 'success' || f.status === 'error').length} of {files.length} done
</span>
</div>
)}
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="px-5 py-2 text-secondary-600 hover:bg-secondary-100 rounded-lg transition-colors font-bold text-sm"
>
{files.every(f => f.status !== 'pending') && files.length > 0 ? 'Finish' : 'Cancel'}
</button>
<button
onClick={processQueue}
disabled={files.length === 0 || isProcessing || files.every(f => f.status !== 'pending')}
className="px-8 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-all font-bold text-sm flex items-center gap-2 shadow-lg shadow-green-500/30 disabled:opacity-50 disabled:cursor-not-allowed hover:scale-[1.02] active:scale-[0.98]"
>
{isProcessing ? (
<>
<Loader className="w-4 h-4 animate-spin" />
Processing Batch...
</>
) : (
<>
<PlayCircle className="w-5 h-5" />
Confirm and Upload
</>
)}
</button>
</div>
</div>
</motion.div>
</div>
);
};
export default BulkUploadModal;