Spaces:
Runtime error
Runtime error
| 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; | |