feat: Initialize OmniSupport AI project structure
Sets up the basic project structure for OmniSupport AI, including: - Vite build tool configuration. - React and necessary dependencies. - TypeScript configuration. - Basic HTML and root component setup. - Initial type definitions and mock data for core entities like tickets, agents, and queues. - A README with setup instructions. - A .gitignore file for common build artifacts and development files.
This commit is contained in:
1789
components/AgentDashboard.tsx
Normal file
1789
components/AgentDashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
199
components/AuthScreen.tsx
Normal file
199
components/AuthScreen.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Agent, ClientUser, AppSettings } from '../types';
|
||||
import { Lock, User, Mail, Briefcase, ArrowRight, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface AuthScreenProps {
|
||||
settings: AppSettings;
|
||||
onClientLogin: (email: string, password: string) => boolean;
|
||||
onAgentLogin: (email: string, password: string) => boolean;
|
||||
onClientRegister: (name: string, email: string, password: string, company: string) => void;
|
||||
}
|
||||
|
||||
export const AuthScreen: React.FC<AuthScreenProps> = ({ settings, onClientLogin, onAgentLogin, onClientRegister }) => {
|
||||
const [activeTab, setActiveTab] = useState<'client' | 'agent'>('client');
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Form States
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [company, setCompany] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
let success = false;
|
||||
|
||||
if (activeTab === 'agent') {
|
||||
success = onAgentLogin(email, password);
|
||||
} else {
|
||||
if (isRegistering) {
|
||||
onClientRegister(name, email, password, company);
|
||||
success = true; // Assume success for mock registration
|
||||
} else {
|
||||
success = onClientLogin(email, password);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
setError("Credenziali non valide. Per favore controlla email e password.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: 'client' | 'agent') => {
|
||||
setActiveTab(tab);
|
||||
setIsRegistering(false);
|
||||
setError(null);
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center items-center p-4">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100">
|
||||
<div className="bg-white p-8 pb-0 text-center">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 text-white font-bold text-2xl"
|
||||
style={{ backgroundColor: settings.branding.primaryColor }}
|
||||
>
|
||||
{settings.branding.appName.substring(0, 2)}
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{settings.branding.appName}</h2>
|
||||
<p className="text-gray-500 mt-2 text-sm">Piattaforma di Supporto & Assistenza</p>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-gray-200 mt-8">
|
||||
<button
|
||||
onClick={() => handleTabChange('client')}
|
||||
className={`flex-1 py-4 text-sm font-medium transition ${activeTab === 'client' ? 'border-b-2 text-gray-900' : 'text-gray-400 hover:text-gray-600'}`}
|
||||
style={activeTab === 'client' ? { borderColor: settings.branding.primaryColor } : {}}
|
||||
>
|
||||
Cliente
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('agent')}
|
||||
className={`flex-1 py-4 text-sm font-medium transition ${activeTab === 'agent' ? 'border-b-2 text-gray-900' : 'text-gray-400 hover:text-gray-600'}`}
|
||||
style={activeTab === 'agent' ? { borderColor: settings.branding.primaryColor } : {}}
|
||||
>
|
||||
Agente / Staff
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-6">
|
||||
{activeTab === 'agent' ? 'Accesso Area Riservata' : (isRegistering ? 'Crea un nuovo account' : 'Accedi al tuo account')}
|
||||
</h3>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center text-red-700 text-sm animate-pulse">
|
||||
<AlertCircle className="w-5 h-5 mr-2 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{isRegistering && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nome Completo</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-2.5 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||
placeholder="Mario Rossi"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-2.5 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
className={`w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 bg-white text-gray-900 ${error ? 'border-red-300 focus:ring-red-200' : 'border-gray-300 focus:ring-blue-500'}`}
|
||||
placeholder="nome@esempio.com"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(null); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isRegistering && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Azienda (Opzionale)</label>
|
||||
<div className="relative">
|
||||
<Briefcase className="absolute left-3 top-2.5 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||
placeholder="La tua azienda"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-2.5 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
className={`w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 bg-white text-gray-900 ${error ? 'border-red-300 focus:ring-red-200' : 'border-gray-300 focus:ring-blue-500'}`}
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(null); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 rounded-lg text-white font-bold shadow-md hover:opacity-90 transition flex justify-center items-center mt-6"
|
||||
style={{ backgroundColor: settings.branding.primaryColor }}
|
||||
>
|
||||
{activeTab === 'agent' ? 'Entra in Dashboard' : (isRegistering ? 'Registrati' : 'Accedi')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{activeTab === 'client' && (
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
{isRegistering ? 'Hai già un account?' : 'Non hai un account?'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsRegistering(!isRegistering); setError(null); }}
|
||||
className="font-bold ml-1 hover:underline"
|
||||
style={{ color: settings.branding.primaryColor }}
|
||||
>
|
||||
{isRegistering ? 'Accedi' : 'Registrati ora'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'agent' && (
|
||||
<div className="mt-6 text-center bg-blue-50 p-2 rounded text-xs text-blue-700">
|
||||
Demo: Usa <b>mario@omni.ai</b> / <b>admin</b>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'client' && !isRegistering && (
|
||||
<div className="mt-6 text-center bg-gray-50 p-2 rounded text-xs text-gray-700">
|
||||
Demo: Usa <b>luca@client.com</b> / <b>user</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
755
components/ClientPortal.tsx
Normal file
755
components/ClientPortal.tsx
Normal file
@@ -0,0 +1,755 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Ticket, KBArticle, ChatMessage, TicketPriority, TicketStatus, SurveyResult, Attachment, ClientUser, TicketQueue } from '../types';
|
||||
import { getSupportResponse } from '../services/geminiService';
|
||||
import { ToastType } from './Toast';
|
||||
import {
|
||||
Search,
|
||||
Send,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
MessageSquare,
|
||||
Star,
|
||||
X,
|
||||
Paperclip,
|
||||
LogOut,
|
||||
Home,
|
||||
PlusCircle,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
ArrowLeft,
|
||||
MoreVertical,
|
||||
Minus,
|
||||
ExternalLink,
|
||||
BookOpen
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ClientPortalProps {
|
||||
currentUser: ClientUser;
|
||||
articles: KBArticle[];
|
||||
queues: TicketQueue[];
|
||||
onCreateTicket: (ticket: Omit<Ticket, 'id' | 'createdAt' | 'messages' | 'status'>) => void;
|
||||
onReplyTicket: (ticketId: string, message: string) => void;
|
||||
onSubmitSurvey: (survey: Omit<SurveyResult, 'id' | 'timestamp'>) => void;
|
||||
tickets?: Ticket[];
|
||||
onLogout: () => void;
|
||||
showToast: (message: string, type: ToastType) => void;
|
||||
}
|
||||
|
||||
export const ClientPortal: React.FC<ClientPortalProps> = ({
|
||||
currentUser,
|
||||
articles,
|
||||
queues,
|
||||
onCreateTicket,
|
||||
onReplyTicket,
|
||||
onSubmitSurvey,
|
||||
tickets = [],
|
||||
onLogout,
|
||||
showToast
|
||||
}) => {
|
||||
const [activeView, setActiveView] = useState<'dashboard' | 'create_ticket' | 'ticket_detail' | 'kb'>('dashboard');
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// KB Modal State
|
||||
const [viewingArticle, setViewingArticle] = useState<KBArticle | null>(null);
|
||||
|
||||
// Chat Widget State
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([{
|
||||
id: '0',
|
||||
role: 'assistant',
|
||||
content: `Ciao ${currentUser.name}! 👋\nSono l'assistente AI. Come posso aiutarti oggi?`,
|
||||
timestamp: new Date().toISOString()
|
||||
}]);
|
||||
const [inputMessage, setInputMessage] = useState('');
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Survey State
|
||||
const [showSurvey, setShowSurvey] = useState(false);
|
||||
const [surveyData, setSurveyData] = useState({ rating: 0, comment: '', context: '' as 'chat' | 'ticket', refId: '' });
|
||||
|
||||
// Ticket Creation Form
|
||||
const [ticketForm, setTicketForm] = useState({
|
||||
subject: '',
|
||||
description: '',
|
||||
queue: queues.length > 0 ? queues[0].name : 'General'
|
||||
});
|
||||
const [ticketFiles, setTicketFiles] = useState<FileList | null>(null);
|
||||
|
||||
// Ticket Reply State
|
||||
const [replyText, setReplyText] = useState('');
|
||||
|
||||
// --- Logic ---
|
||||
|
||||
useEffect(() => {
|
||||
if (isChatOpen) chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [chatMessages, isChatOpen]);
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputMessage.trim()) return;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: inputMessage,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
setChatMessages(prev => [...prev, userMsg]);
|
||||
setInputMessage('');
|
||||
setIsTyping(true);
|
||||
|
||||
const aiResponseText = await getSupportResponse(
|
||||
userMsg.content,
|
||||
chatMessages.map(m => m.content).slice(-5),
|
||||
articles
|
||||
);
|
||||
|
||||
const aiMsg: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: aiResponseText,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
setChatMessages(prev => [...prev, aiMsg]);
|
||||
setIsTyping(false);
|
||||
};
|
||||
|
||||
const submitTicket = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const attachments: Attachment[] = [];
|
||||
if (ticketFiles) {
|
||||
for (let i = 0; i < ticketFiles.length; i++) {
|
||||
const file = ticketFiles[i];
|
||||
attachments.push({
|
||||
id: `att-${Date.now()}-${i}`,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
url: URL.createObjectURL(file)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onCreateTicket({
|
||||
subject: ticketForm.subject,
|
||||
description: ticketForm.description,
|
||||
customerName: currentUser.name,
|
||||
priority: TicketPriority.MEDIUM,
|
||||
queue: ticketForm.queue,
|
||||
attachments: attachments
|
||||
});
|
||||
|
||||
setTicketForm({ subject: '', description: '', queue: queues.length > 0 ? queues[0].name : 'General' });
|
||||
setTicketFiles(null);
|
||||
setActiveView('dashboard');
|
||||
};
|
||||
|
||||
const submitReply = () => {
|
||||
if (!replyText.trim() || !selectedTicket) return;
|
||||
onReplyTicket(selectedTicket.id, replyText);
|
||||
setReplyText('');
|
||||
// Optimistic update for UI smoothness (actual update comes from props change)
|
||||
const newMsg: ChatMessage = { id: `temp-${Date.now()}`, role: 'user', content: replyText, timestamp: new Date().toISOString() };
|
||||
setSelectedTicket({
|
||||
...selectedTicket,
|
||||
messages: [...selectedTicket.messages, newMsg],
|
||||
status: selectedTicket.status === TicketStatus.RESOLVED ? TicketStatus.OPEN : selectedTicket.status
|
||||
});
|
||||
showToast("Risposta inviata", 'success');
|
||||
};
|
||||
|
||||
const submitSurvey = () => {
|
||||
if (surveyData.rating === 0) {
|
||||
showToast("Per favore seleziona una valutazione.", 'error');
|
||||
return;
|
||||
}
|
||||
onSubmitSurvey({
|
||||
rating: surveyData.rating,
|
||||
comment: surveyData.comment,
|
||||
source: surveyData.context,
|
||||
referenceId: surveyData.refId || undefined
|
||||
});
|
||||
setShowSurvey(false);
|
||||
if (surveyData.context === 'chat') {
|
||||
setChatMessages([{ id: '0', role: 'assistant', content: `Ciao ${currentUser.name}! Come posso aiutarti oggi?`, timestamp: new Date().toISOString() }]);
|
||||
setIsChatOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTicketClick = (ticket: Ticket) => {
|
||||
setSelectedTicket(ticket);
|
||||
setActiveView('ticket_detail');
|
||||
};
|
||||
|
||||
const activeTickets = tickets.filter(t => t.status !== TicketStatus.RESOLVED && t.status !== TicketStatus.CLOSED);
|
||||
const resolvedTickets = tickets.filter(t => t.status === TicketStatus.RESOLVED || t.status === TicketStatus.CLOSED);
|
||||
|
||||
const filteredArticles = articles.filter(a =>
|
||||
a.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
a.content.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col font-sans">
|
||||
{/* Top Navigation */}
|
||||
<nav className="bg-white border-b border-gray-200 sticky top-0 z-30 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 flex items-center cursor-pointer" onClick={() => setActiveView('dashboard')}>
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-600 flex items-center justify-center text-white font-bold mr-2">
|
||||
OS
|
||||
</div>
|
||||
<span className="font-bold text-xl text-gray-800 tracking-tight">OmniSupport</span>
|
||||
</div>
|
||||
<div className="hidden sm:ml-10 sm:flex sm:space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveView('dashboard')}
|
||||
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${activeView === 'dashboard' ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('kb')}
|
||||
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${activeView === 'kb' ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}
|
||||
>
|
||||
Knowledge Base
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => setActiveView('create_ticket')}
|
||||
className="bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-700 shadow-sm transition mr-4 flex items-center"
|
||||
>
|
||||
<PlusCircle className="w-4 h-4 mr-2" />
|
||||
Nuovo Ticket
|
||||
</button>
|
||||
<div className="ml-3 relative flex items-center cursor-pointer group">
|
||||
<div className="h-8 w-8 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-bold border border-gray-300">
|
||||
{currentUser.name.substring(0,2).toUpperCase()}
|
||||
</div>
|
||||
<button onClick={onLogout} className="ml-4 text-gray-400 hover:text-red-500">
|
||||
<LogOut className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
{/* DASHBOARD VIEW */}
|
||||
{activeView === 'dashboard' && (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Ticket Aperti</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{activeTickets.filter(t => t.status === TicketStatus.OPEN).length}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-50 rounded-full text-blue-600">
|
||||
<AlertCircle className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">In Lavorazione</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{activeTickets.filter(t => t.status === TicketStatus.IN_PROGRESS).length}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-amber-50 rounded-full text-amber-600">
|
||||
<Clock className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Risolti Totali</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{resolvedTickets.length}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-50 rounded-full text-green-600">
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Active Tickets List */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold text-gray-800">I tuoi Ticket Attivi</h2>
|
||||
<span className="text-sm text-gray-500">{activeTickets.length} in corso</span>
|
||||
</div>
|
||||
|
||||
{activeTickets.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
|
||||
<CheckCircle className="w-12 h-12 text-green-200 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Tutto tranquillo!</h3>
|
||||
<p className="text-gray-500 mt-1">Non hai ticket aperti al momento.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="divide-y divide-gray-100">
|
||||
{activeTickets.map(ticket => (
|
||||
<div
|
||||
key={ticket.id}
|
||||
onClick={() => handleTicketClick(ticket)}
|
||||
className="p-6 hover:bg-gray-50 transition cursor-pointer flex items-center justify-between group"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-1">
|
||||
<span className="text-sm font-mono text-gray-500">#{ticket.id}</span>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
ticket.status === TicketStatus.OPEN ? 'bg-blue-100 text-blue-800' : 'bg-amber-100 text-amber-800'
|
||||
}`}>
|
||||
{ticket.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">{ticket.queue}</span>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 group-hover:text-brand-600 transition">{ticket.subject}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-1">{ticket.description}</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-300 group-hover:text-gray-400" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resolved History */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold text-gray-800">Storico Recente</h2>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
{resolvedTickets.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400 text-sm">Nessun ticket nello storico.</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{resolvedTickets.slice(0, 5).map(ticket => (
|
||||
<div key={ticket.id} onClick={() => handleTicketClick(ticket)} className="p-4 hover:bg-gray-50 cursor-pointer transition">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 line-clamp-1">{ticket.subject}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{ticket.createdAt.split('T')[0]}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-green-100 text-green-800">
|
||||
{ticket.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{resolvedTickets.length > 5 && (
|
||||
<div className="p-3 bg-gray-50 text-center border-t border-gray-100">
|
||||
<button className="text-xs font-medium text-brand-600 hover:text-brand-800">Visualizza tutti</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TICKET DETAIL VIEW */}
|
||||
{activeView === 'ticket_detail' && selectedTicket && (
|
||||
<div className="max-w-4xl mx-auto animate-fade-in">
|
||||
<button onClick={() => setActiveView('dashboard')} className="flex items-center text-gray-500 hover:text-gray-900 mb-6 transition">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Torna alla Dashboard
|
||||
</button>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 flex justify-between items-start bg-gray-50">
|
||||
<div>
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<span className="font-mono text-gray-500 text-sm">#{selectedTicket.id}</span>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-bold uppercase ${
|
||||
selectedTicket.status === TicketStatus.RESOLVED ? 'bg-green-100 text-green-700' :
|
||||
selectedTicket.status === TicketStatus.OPEN ? 'bg-blue-100 text-blue-700' : 'bg-amber-100 text-amber-700'
|
||||
}`}>
|
||||
{selectedTicket.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 flex items-center">
|
||||
<Clock className="w-3 h-3 mr-1" /> {selectedTicket.createdAt.split('T')[0]}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{selectedTicket.subject}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Coda: <span className="font-medium">{selectedTicket.queue}</span></p>
|
||||
</div>
|
||||
{selectedTicket.status === TicketStatus.RESOLVED && (
|
||||
<button
|
||||
onClick={() => { setSurveyData({rating: 0, comment: '', context: 'ticket', refId: selectedTicket.id}); setShowSurvey(true); }}
|
||||
className="bg-amber-100 text-amber-700 px-4 py-2 rounded-lg text-sm font-bold hover:bg-amber-200 transition flex items-center"
|
||||
>
|
||||
<Star className="w-4 h-4 mr-2" /> Valuta
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="p-8 border-b border-gray-100">
|
||||
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-wide mb-3">Descrizione Problema</h3>
|
||||
<div className="text-gray-800 leading-relaxed bg-gray-50 p-4 rounded-lg border border-gray-100">
|
||||
{selectedTicket.description}
|
||||
</div>
|
||||
{selectedTicket.attachments && selectedTicket.attachments.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{selectedTicket.attachments.map(att => (
|
||||
<a key={att.id} href="#" className="flex items-center px-3 py-2 bg-white border border-gray-200 rounded-lg text-sm text-blue-600 hover:border-blue-300 transition">
|
||||
<Paperclip className="w-4 h-4 mr-2 text-gray-400" />
|
||||
{att.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conversation */}
|
||||
<div className="p-8 bg-white space-y-6">
|
||||
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-wide mb-4">Cronologia Conversazione</h3>
|
||||
|
||||
{selectedTicket.messages.length === 0 && (
|
||||
<p className="text-center text-gray-400 italic py-4">Nessuna risposta ancora. Un agente ti risponderà presto.</p>
|
||||
)}
|
||||
|
||||
{selectedTicket.messages.map(msg => (
|
||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[80%] p-4 rounded-xl text-sm ${
|
||||
msg.role === 'user'
|
||||
? 'bg-blue-600 text-white rounded-tr-none shadow-md'
|
||||
: 'bg-gray-100 text-gray-800 rounded-tl-none border border-gray-200'
|
||||
}`}>
|
||||
<div className="flex justify-between items-center mb-1 opacity-80 text-xs">
|
||||
<span className="font-bold mr-4">{msg.role === 'user' ? 'Tu' : 'Supporto'}</span>
|
||||
<span>{msg.timestamp.split('T')[1].substring(0,5)}</span>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reply Box */}
|
||||
<div className="p-6 bg-gray-50 border-t border-gray-200">
|
||||
<h3 className="text-sm font-bold text-gray-700 mb-3">Rispondi</h3>
|
||||
<textarea
|
||||
className="w-full border border-gray-300 rounded-lg p-4 focus:ring-2 focus:ring-brand-500 focus:outline-none shadow-sm mb-3 bg-white text-gray-900"
|
||||
rows={3}
|
||||
placeholder="Scrivi qui la tua risposta..."
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={submitReply}
|
||||
disabled={!replyText.trim()}
|
||||
className="bg-brand-600 text-white px-6 py-2.5 rounded-lg font-bold hover:bg-brand-700 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" /> Invia Risposta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KB VIEW */}
|
||||
{activeView === 'kb' && (
|
||||
<div className="max-w-5xl mx-auto animate-fade-in">
|
||||
<div className="text-center py-10">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">Knowledge Base</h1>
|
||||
<p className="text-gray-500 mb-8 max-w-2xl mx-auto">Trova risposte rapide alle domande più comuni, guide tecniche e documentazione.</p>
|
||||
<div className="relative max-w-xl mx-auto">
|
||||
<Search className="absolute left-4 top-3.5 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cerca articoli, guide..."
|
||||
className="w-full pl-12 pr-4 py-3 rounded-full border border-gray-300 focus:outline-none focus:ring-2 focus:ring-brand-500 shadow-sm text-lg bg-white text-gray-900"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredArticles.map(article => (
|
||||
<div
|
||||
key={article.id}
|
||||
onClick={() => setViewingArticle(article)}
|
||||
className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition group cursor-pointer hover:border-brand-200"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span className="text-xs font-bold text-brand-600 bg-brand-50 px-2 py-1 rounded-md uppercase tracking-wide">
|
||||
{article.category}
|
||||
</span>
|
||||
{article.type === 'url' && <ExternalLink className="w-4 h-4 text-gray-300" />}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-2 group-hover:text-brand-600 transition">{article.title}</h3>
|
||||
<p className="text-gray-600 text-sm line-clamp-3 mb-4">
|
||||
{article.content}
|
||||
</p>
|
||||
{article.type === 'url' ? (
|
||||
<span className="text-brand-600 text-sm font-medium hover:underline flex items-center">
|
||||
Apri Link <ChevronRight className="w-4 h-4 ml-1" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-brand-600 text-sm font-medium group-hover:underline flex items-center">
|
||||
Leggi Articolo <ChevronRight className="w-4 h-4 ml-1" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KB Article Modal */}
|
||||
{viewingArticle && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 backdrop-blur-sm animate-fade-in">
|
||||
<div className="bg-white rounded-xl w-full max-w-2xl max-h-[85vh] shadow-2xl flex flex-col">
|
||||
<div className="p-6 border-b border-gray-100 flex justify-between items-start bg-gray-50 rounded-t-xl">
|
||||
<div>
|
||||
<span className="text-xs font-bold text-brand-600 bg-brand-50 px-2 py-1 rounded-md uppercase tracking-wide mb-2 inline-block">
|
||||
{viewingArticle.category}
|
||||
</span>
|
||||
<h2 className="text-xl font-bold text-gray-900">{viewingArticle.title}</h2>
|
||||
{viewingArticle.type === 'url' && (
|
||||
<a href={viewingArticle.url} target="_blank" rel="noreferrer" className="text-sm text-blue-600 hover:underline flex items-center mt-1">
|
||||
{viewingArticle.url} <ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setViewingArticle(null)} className="p-2 bg-white rounded-full text-gray-400 hover:text-gray-700 hover:bg-gray-100 border border-gray-200 shadow-sm transition">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 overflow-y-auto">
|
||||
<div className="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap">
|
||||
{viewingArticle.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-100 bg-gray-50 rounded-b-xl flex justify-between items-center text-sm text-gray-500">
|
||||
<span>Ultimo aggiornamento: {viewingArticle.lastUpdated}</span>
|
||||
<button onClick={() => setViewingArticle(null)} className="px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 font-medium">Chiudi</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CREATE TICKET VIEW */}
|
||||
{activeView === 'create_ticket' && (
|
||||
<div className="max-w-2xl mx-auto animate-fade-in">
|
||||
<button onClick={() => setActiveView('dashboard')} className="flex items-center text-gray-500 hover:text-gray-900 mb-6 transition">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Annulla e Torna indietro
|
||||
</button>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-6">Nuova Richiesta di Supporto</h2>
|
||||
|
||||
<form onSubmit={submitTicket} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">Di cosa hai bisogno?</label>
|
||||
<select
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white text-gray-900"
|
||||
value={ticketForm.queue}
|
||||
onChange={e => setTicketForm({...ticketForm, queue: e.target.value})}
|
||||
>
|
||||
{queues.map(q => (
|
||||
<option key={q.id} value={q.name}>{q.name} - {q.description}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">Oggetto</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="Breve sintesi del problema"
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white text-gray-900"
|
||||
value={ticketForm.subject}
|
||||
onChange={e => setTicketForm({...ticketForm, subject: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">Descrizione Dettagliata</label>
|
||||
<textarea
|
||||
required
|
||||
rows={5}
|
||||
placeholder="Descrivi i passaggi per riprodurre il problema..."
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white text-gray-900"
|
||||
value={ticketForm.description}
|
||||
onChange={e => setTicketForm({...ticketForm, description: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">Allegati</label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center text-center hover:bg-gray-50 transition cursor-pointer relative">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
onChange={(e) => setTicketFiles(e.target.files)}
|
||||
/>
|
||||
<div className="bg-blue-50 p-3 rounded-full mb-3">
|
||||
<Paperclip className="w-6 h-6 text-brand-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{ticketFiles && ticketFiles.length > 0
|
||||
? <span className="text-green-600">{ticketFiles.length} file pronti per l'upload</span>
|
||||
: 'Clicca per caricare file'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Supporta immagini e PDF</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="w-full bg-brand-600 text-white font-bold py-4 rounded-xl hover:bg-brand-700 transition shadow-md">
|
||||
Invia Richiesta
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* FLOATING CHAT WIDGET */}
|
||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
|
||||
{isChatOpen && (
|
||||
<div className="mb-4 bg-white w-96 rounded-2xl shadow-2xl border border-gray-200 overflow-hidden flex flex-col h-[500px] animate-slide-up origin-bottom-right">
|
||||
<div className="bg-gradient-to-r from-brand-600 to-brand-700 p-4 flex justify-between items-center text-white shadow-md">
|
||||
<div className="flex items-center">
|
||||
<div className="bg-white/20 p-2 rounded-lg mr-3">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-sm">Assistente AI</h3>
|
||||
<p className="text-[10px] text-brand-100 opacity-90">Supporto Istantaneo H24</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<button onClick={() => { setSurveyData({rating: 0, comment: '', context: 'chat', refId: ''}); setShowSurvey(true); }} className="p-1 hover:bg-white/20 rounded">
|
||||
<Star className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setIsChatOpen(false)} className="p-1 hover:bg-white/20 rounded">
|
||||
<Minus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-gray-50 space-y-3">
|
||||
{chatMessages.map(msg => (
|
||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="w-6 h-6 rounded-full bg-brand-100 flex items-center justify-center mr-2 mt-1 flex-shrink-0 text-brand-700 text-[10px] font-bold">AI</div>
|
||||
)}
|
||||
<div className={`max-w-[85%] p-3 text-sm rounded-2xl shadow-sm ${
|
||||
msg.role === 'user'
|
||||
? 'bg-brand-600 text-white rounded-tr-none'
|
||||
: 'bg-white text-gray-800 rounded-tl-none border border-gray-100'
|
||||
}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="w-6 h-6 rounded-full bg-brand-100 flex items-center justify-center mr-2 mt-1"></div>
|
||||
<div className="bg-white p-3 rounded-2xl rounded-tl-none border border-gray-100 shadow-sm flex space-x-1 items-center">
|
||||
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
|
||||
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" style={{animationDelay: '0.4s'}}></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white border-t border-gray-100">
|
||||
<div className="flex items-center bg-gray-50 rounded-full px-4 py-2 border border-gray-200 focus-within:ring-2 focus-within:ring-brand-500 focus-within:border-transparent transition">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 bg-transparent border-none focus:outline-none text-sm text-gray-900"
|
||||
placeholder="Scrivi un messaggio..."
|
||||
value={inputMessage}
|
||||
onChange={e => setInputMessage(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSendMessage()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputMessage.trim() || isTyping}
|
||||
className="ml-2 text-brand-600 hover:text-brand-800 disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setIsChatOpen(!isChatOpen)}
|
||||
className={`w-14 h-14 rounded-full shadow-lg flex items-center justify-center transition transform hover:scale-105 ${isChatOpen ? 'bg-gray-800 rotate-90' : 'bg-brand-600 hover:bg-brand-700'} text-white`}
|
||||
>
|
||||
{isChatOpen ? <X className="w-6 h-6" /> : <MessageSquare className="w-7 h-7" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Survey Modal */}
|
||||
{showSurvey && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60] p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-sm shadow-2xl animate-scale-in">
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-800">Valuta l'esperienza</h3>
|
||||
<p className="text-gray-500 text-sm mt-1">Il tuo parere conta per noi!</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center space-x-3 mb-6">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => setSurveyData(s => ({ ...s, rating: star }))}
|
||||
className={`transition transform hover:scale-110 p-1`}
|
||||
>
|
||||
<Star className={`w-8 h-8 ${surveyData.rating >= star ? 'fill-amber-400 text-amber-400' : 'text-gray-200'}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="w-full border border-gray-300 rounded-lg p-3 text-sm focus:ring-2 focus:ring-brand-500 mb-4 bg-white text-gray-900 resize-none"
|
||||
rows={3}
|
||||
placeholder="Scrivi un commento (opzionale)..."
|
||||
value={surveyData.comment}
|
||||
onChange={e => setSurveyData(s => ({...s, comment: e.target.value}))}
|
||||
/>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowSurvey(false)}
|
||||
className="flex-1 py-2.5 text-gray-500 font-medium hover:bg-gray-100 rounded-xl transition"
|
||||
>
|
||||
Salta
|
||||
</button>
|
||||
<button
|
||||
onClick={submitSurvey}
|
||||
className="flex-1 py-2.5 bg-brand-600 text-white font-bold rounded-xl hover:bg-brand-700 transition shadow-lg shadow-brand-200"
|
||||
>
|
||||
Invia
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
57
components/Toast.tsx
Normal file
57
components/Toast.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
toasts: ToastMessage[];
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ToastContainer: React.FC<ToastProps> = ({ toasts, removeToast }) => {
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-[100] flex flex-col gap-2 pointer-events-none">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} removeToast={removeToast} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ToastItem: React.FC<{ toast: ToastMessage; removeToast: (id: string) => void }> = ({ toast, removeToast }) => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
removeToast(toast.id);
|
||||
}, 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.id, removeToast]);
|
||||
|
||||
const styles = {
|
||||
success: 'bg-green-600 text-white',
|
||||
error: 'bg-red-600 text-white',
|
||||
info: 'bg-blue-600 text-white',
|
||||
};
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle className="w-5 h-5" />,
|
||||
error: <AlertCircle className="w-5 h-5" />,
|
||||
info: <Info className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles[toast.type]} pointer-events-auto flex items-center p-4 rounded-lg shadow-lg min-w-[300px] animate-slide-up transition-all transform hover:scale-105`}>
|
||||
<div className="mr-3">{icons[toast.type]}</div>
|
||||
<div className="flex-1 text-sm font-medium">{toast.message}</div>
|
||||
<button onClick={() => removeToast(toast.id)} className="ml-4 opacity-70 hover:opacity-100">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user