Files
auditly/pages/ChatNew.tsx
Ra cf565df13e feat: major UI overhaul with new components and enhanced UX
- Add comprehensive Company Wiki feature with complete state management
  - CompanyWikiManager, empty states, invite modals
- Implement new Chat system with enhanced layout and components
  - ChatLayout, ChatSidebar, MessageThread, FileUploadInput
- Create modern Login and OTP verification flows
  - LoginNew page, OTPVerification component
- Add new Employee Forms system with enhanced controller
- Introduce Figma-based design components and multiple choice inputs
- Add new font assets (NeueMontreal) and robot images for onboarding
- Enhance existing components with improved styling and functionality
- Update build configuration and dependencies
- Remove deprecated ModernLogin component
2025-08-20 04:06:49 -07:00

390 lines
22 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useOrg } from '../contexts/OrgContext';
import Sidebar from '../components/figma/Sidebar';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
interface ChatState {
messages: Message[];
isLoading: boolean;
showEmployeeMenu: boolean;
mentionQuery: string;
selectedEmployees: string[];
hasUploadedFiles: boolean;
uploadedFiles: Array<{
name: string;
type: string;
size: number;
}>;
}
const ChatNew: React.FC = () => {
const { user } = useAuth();
const { employees, orgId } = useOrg();
const navigate = useNavigate();
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState<ChatState>({
messages: [],
isLoading: false,
showEmployeeMenu: false,
mentionQuery: '',
selectedEmployees: [],
hasUploadedFiles: false,
uploadedFiles: []
});
const [currentInput, setCurrentInput] = useState('');
const [selectedCategory, setSelectedCategory] = useState('Accountability');
useEffect(() => {
if (!user) {
navigate('/login');
}
}, [user, navigate]);
const questionStarters = [
"How can the company serve them better?",
"How can the company serve them better?",
"How can the company serve them better?",
"How can the company serve them better?"
];
const categories = ['Accountability', 'Employee Growth', 'Customer Focus', 'Teamwork'];
const handleSendMessage = async () => {
if (!currentInput.trim() && state.uploadedFiles.length === 0) return;
const newMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: currentInput.trim(),
timestamp: new Date()
};
setState(prev => ({
...prev,
messages: [...prev.messages, newMessage],
isLoading: true
}));
setCurrentInput('');
// Simulate AI response
setTimeout(() => {
const aiResponse: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: "Based on the information provided and the company data, here are my insights and recommendations...",
timestamp: new Date()
};
setState(prev => ({
...prev,
messages: [...prev.messages, aiResponse],
isLoading: false
}));
}, 2000);
};
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setCurrentInput(value);
// Check for @ mentions
const atIndex = value.lastIndexOf('@');
if (atIndex !== -1 && atIndex === value.length - 1) {
setState(prev => ({
...prev,
showEmployeeMenu: true,
mentionQuery: ''
}));
} else if (atIndex !== -1 && value.length > atIndex + 1) {
const query = value.substring(atIndex + 1);
setState(prev => ({
...prev,
showEmployeeMenu: true,
mentionQuery: query
}));
} else {
setState(prev => ({
...prev,
showEmployeeMenu: false,
mentionQuery: ''
}));
}
};
const handleEmployeeSelect = (employeeName: string) => {
const atIndex = currentInput.lastIndexOf('@');
if (atIndex !== -1) {
const newInput = currentInput.substring(0, atIndex) + '@' + employeeName + ' ';
setCurrentInput(newInput);
}
setState(prev => ({
...prev,
showEmployeeMenu: false,
mentionQuery: ''
}));
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
const uploadedFiles = files.map(file => ({
name: file.name,
type: file.type,
size: file.size
}));
setState(prev => ({
...prev,
hasUploadedFiles: true,
uploadedFiles: [...prev.uploadedFiles, ...uploadedFiles]
}));
}
};
const removeFile = (index: number) => {
setState(prev => ({
...prev,
uploadedFiles: prev.uploadedFiles.filter((_, i) => i !== index),
hasUploadedFiles: prev.uploadedFiles.length > 1
}));
};
const handleQuestionClick = (question: string) => {
setCurrentInput(question);
};
const renderEmployeeMenu = () => {
if (!state.showEmployeeMenu) return null;
const filteredEmployees = employees.filter(emp =>
emp.name.toLowerCase().includes(state.mentionQuery.toLowerCase())
);
return (
<div className="absolute bottom-full left-[285px] mb-2 w-48 p-2 bg-Text-White-00 rounded-2xl shadow-[0px_1px_4px_4px_rgba(14,18,27,0.08)] flex flex-col justify-start items-start gap-1 z-10">
{filteredEmployees.slice(0, 3).map((employee, index) => (
<div
key={employee.id}
onClick={() => handleEmployeeSelect(employee.name)}
className={`self-stretch px-3 py-2 rounded-xl flex flex-col justify-start items-start gap-2.5 overflow-hidden cursor-pointer hover:bg-Text-Gray-100 ${index === 2 ? 'bg-Text-Gray-100' : ''
}`}
>
<div className={`self-stretch justify-start text-Text-Dark-950 text-sm leading-tight ${index === 2 ? 'font-medium' : 'font-normal'
}`}>
{employee.name}
</div>
</div>
))}
</div>
);
};
const renderUploadedFiles = () => {
if (state.uploadedFiles.length === 0) return null;
return (
<div className="inline-flex justify-start items-center gap-3 mb-4">
{state.uploadedFiles.map((file, index) => (
<div key={index} className="w-40 max-w-40 p-2 bg-Text-White-00 rounded-full outline outline-1 outline-offset-[-1px] outline-Outline-Outline-Gray-200 inline-flex flex-col justify-start items-start gap-2.5 overflow-hidden">
<div className="self-stretch pr-2 inline-flex justify-between items-center">
<div className="flex-1 flex justify-start items-center gap-1.5">
<div className="w-6 h-6 relative bg-Icon-Gray-600 rounded-full overflow-hidden">
<div className="left-[6px] top-[6px] absolute">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 1.13477V3.20004C7 3.48006 7 3.62007 7.0545 3.72703C7.10243 3.82111 7.17892 3.8976 7.273 3.94554C7.37996 4.00004 7.51997 4.00004 7.8 4.00004H9.86527M8 6.5H4M8 8.5H4M5 4.5H4M7 1H4.4C3.55992 1 3.13988 1 2.81901 1.16349C2.53677 1.3073 2.3073 1.53677 2.16349 1.81901C2 2.13988 2 2.55992 2 3.4V8.6C2 9.44008 2 9.86012 2.16349 10.181C2.3073 10.4632 2.53677 10.6927 2.81901 10.8365C3.13988 11 3.55992 11 4.4 11H7.6C8.44008 11 8.86012 11 9.18099 10.8365C9.46323 10.6927 9.6927 10.4632 9.83651 10.181C10 9.86012 10 9.44008 10 8.6V4L7 1Z" stroke="var(--Text-White-00, #FDFDFD)" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
<div className="flex-1 justify-start text-Text-Dark-950 text-sm font-medium font-['Inter'] leading-tight">{file.name}</div>
</div>
<div onClick={() => removeFile(index)} className="cursor-pointer">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4L4 12M4 4L12 12" stroke="var(--Icon-Gray-400, #A4A7AE)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
</div>
))}
</div>
);
};
const renderChatInterface = () => {
if (state.messages.length === 0) {
return (
<div className="w-[736px] flex-1 max-w-[736px] pt-48 flex flex-col justify-between items-center">
<div className="self-stretch flex flex-col justify-start items-center gap-6">
<div className="justify-start text-Text-Gray-800 text-2xl font-medium font-['Neue_Montreal'] leading-normal">What would you like to understand?</div>
<div className="p-1 bg-Neutrals-NeutralSlate100 rounded-xl inline-flex justify-start items-center gap-1">
{categories.map((category) => (
<div
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-3 py-1.5 rounded-lg shadow-[0px_1px_2px_0px_rgba(16,24,40,0.05)] shadow-[inset_0px_-2px_0px_0px_rgba(10,13,18,0.05)] shadow-[inset_0px_0px_0px_1px_rgba(10,13,18,0.18)] flex justify-center items-center gap-1 overflow-hidden cursor-pointer ${selectedCategory === category ? 'bg-white' : ''
}`}
>
<div className="px-0.5 flex justify-center items-center">
<div className={`justify-start text-xs font-medium font-['Inter'] leading-none ${selectedCategory === category ? 'text-Neutrals-NeutralSlate900' : 'text-Neutrals-NeutralSlate600'
}`}>{category}</div>
</div>
</div>
))}
</div>
<div className="self-stretch flex flex-col justify-start items-start gap-3">
<div className="self-stretch inline-flex justify-start items-center gap-3">
{questionStarters.map((question, index) => (
<div
key={index}
onClick={() => handleQuestionClick(question)}
className="flex-1 h-48 px-3 py-4 bg-Main-BG-Gray-50 rounded-2xl inline-flex flex-col justify-between items-start overflow-hidden cursor-pointer hover:bg-Main-BG-Gray-100"
>
<div>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_818_19557)">
<path d="M7.57496 7.5013C7.77088 6.94436 8.15759 6.47472 8.66659 6.17558C9.17559 5.87643 9.77404 5.76708 10.3559 5.8669C10.9378 5.96671 11.4656 6.26924 11.8459 6.72091C12.2261 7.17258 12.4342 7.74424 12.4333 8.33464C12.4333 10.0013 9.93329 10.8346 9.93329 10.8346M9.99996 14.168H10.0083M18.3333 10.0013C18.3333 14.6037 14.6023 18.3346 9.99996 18.3346C5.39759 18.3346 1.66663 14.6037 1.66663 10.0013C1.66663 5.39893 5.39759 1.66797 9.99996 1.66797C14.6023 1.66797 18.3333 5.39893 18.3333 10.0013Z" stroke="var(--Text-Gray-500, #717680)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</g>
<defs>
<clipPath id="clip0_818_19557">
<rect width="20" height="20" fill="white" />
</clipPath>
</defs>
</svg>
</div>
<div className="self-stretch justify-start text-Text-Gray-800 text-base font-normal font-['Inter'] leading-normal">{question}</div>
</div>
))}
</div>
</div>
</div>
{renderChatInput()}
</div>
);
}
return (
<div className="w-[736px] flex-1 max-w-[736px] flex flex-col">
<div className="flex-1 overflow-y-auto py-6">
{state.messages.map((message) => (
<div key={message.id} className={`mb-4 flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] p-4 rounded-2xl ${message.role === 'user'
? 'bg-Brand-Orange text-white'
: 'bg-Main-BG-Gray-50 text-Text-Gray-800'
}`}>
{message.content}
</div>
</div>
))}
{state.isLoading && (
<div className="flex justify-start mb-4">
<div className="bg-Main-BG-Gray-50 text-Text-Gray-800 p-4 rounded-2xl">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-Text-Gray-500 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-Text-Gray-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-Text-Gray-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</div>
</div>
)}
</div>
{renderChatInput()}
</div>
);
};
const renderChatInput = () => {
return (
<div className="self-stretch pl-5 pr-3 pt-5 pb-3 relative bg-Main-BG-Gray-50 rounded-3xl flex flex-col justify-start items-start gap-4">
{renderUploadedFiles()}
<div className="self-stretch justify-start text-Text-Gray-500 text-base font-normal font-['Inter'] leading-normal">
{currentInput || "Ask anything, use @ to tag staff and ask questions."}
</div>
<div className="self-stretch inline-flex justify-between items-center">
<div className="flex justify-start items-center gap-4">
<div onClick={() => fileInputRef.current?.click()} className="cursor-pointer">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.6271 9.08442L10.1141 16.5974C8.40556 18.306 5.63546 18.306 3.92692 16.5974C2.21837 14.8889 2.21837 12.1188 3.92692 10.4102L11.4399 2.89724C12.579 1.75821 14.4257 1.75821 15.5647 2.89724C16.7037 4.03627 16.7037 5.883 15.5647 7.02203L8.34633 14.2404C7.77682 14.8099 6.85345 14.8099 6.28394 14.2404C5.71442 13.6709 5.71442 12.7475 6.28394 12.178L12.6184 5.84352" stroke="var(--Text-Gray-500, #717680)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div onClick={() => fileInputRef.current?.click()} className="cursor-pointer">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 17.5H5.77614C5.2713 17.5 5.01887 17.5 4.90199 17.4002C4.80056 17.3135 4.74674 17.1836 4.75721 17.0506C4.76927 16.8974 4.94776 16.7189 5.30474 16.3619L12.3905 9.27614C12.7205 8.94613 12.8855 8.78112 13.0758 8.7193C13.2432 8.66492 13.4235 8.66492 13.5908 8.7193C13.7811 8.78112 13.9461 8.94613 14.2761 9.27614L17.5 12.5V13.5M13.5 17.5C14.9001 17.5 15.6002 17.5 16.135 17.2275C16.6054 16.9878 16.9878 16.6054 17.2275 16.135C17.5 15.6002 17.5 14.9001 17.5 13.5M13.5 17.5H6.5C5.09987 17.5 4.3998 17.5 3.86502 17.2275C3.39462 16.9878 3.01217 16.6054 2.77248 16.135C2.5 15.6002 2.5 14.9001 2.5 13.5V6.5C2.5 5.09987 2.5 4.3998 2.77248 3.86502C3.01217 3.39462 3.39462 3.01217 3.86502 2.77248C4.3998 2.5 5.09987 2.5 6.5 2.5H13.5C14.9001 2.5 15.6002 2.5 16.135 2.77248C16.6054 3.01217 16.9878 3.39462 17.2275 3.86502C17.5 4.3998 17.5 5.09987 17.5 6.5V13.5M8.75 7.08333C8.75 8.00381 8.00381 8.75 7.08333 8.75C6.16286 8.75 5.41667 8.00381 5.41667 7.08333C5.41667 6.16286 6.16286 5.41667 7.08333 5.41667C8.00381 5.41667 8.75 6.16286 8.75 7.08333Z" stroke="var(--Text-Gray-500, #717680)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="cursor-pointer">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_818_19694)">
<path d="M13.3334 6.66745V10.8341C13.3334 11.4972 13.5968 12.133 14.0657 12.6019C14.5345 13.0707 15.1704 13.3341 15.8334 13.3341C16.4965 13.3341 17.1324 13.0707 17.6012 12.6019C18.07 12.133 18.3334 11.4972 18.3334 10.8341V10.0008C18.3333 8.11998 17.6969 6.29452 16.5278 4.82123C15.3587 3.34794 13.7256 2.31347 11.894 1.88603C10.0624 1.45859 8.14003 1.66332 6.43955 2.46692C4.73906 3.27053 3.36042 4.62575 2.5278 6.31222C1.69519 7.99869 1.45756 9.91723 1.85356 11.7559C2.24956 13.5945 3.2559 15.2451 4.70895 16.4393C6.16199 17.6335 7.97628 18.3011 9.85681 18.3334C11.7373 18.3657 13.5735 17.761 15.0668 16.6175M13.3334 10.0008C13.3334 11.8417 11.841 13.3341 10.0001 13.3341C8.15914 13.3341 6.66676 11.8417 6.66676 10.0008C6.66676 8.15984 8.15914 6.66745 10.0001 6.66745C11.841 6.66745 13.3334 8.15984 13.3334 10.0008Z" stroke="var(--Text-Gray-500, #717680)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</g>
<defs>
<clipPath id="clip0_818_19694">
<rect width="20" height="20" fill="white" />
</clipPath>
</defs>
</svg>
</div>
</div>
<div
onClick={handleSendMessage}
className={`p-2.5 rounded-[999px] flex justify-start items-center gap-2.5 overflow-hidden cursor-pointer ${currentInput.trim() || state.uploadedFiles.length > 0
? 'bg-Main-BG-Gray-800'
: 'bg-Text-Gray-300'
}`}
>
<div>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 13.3346V2.66797M8 2.66797L4 6.66797M8 2.66797L12 6.66797" stroke="var(--Text-White-00, #FDFDFD)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
</div>
{renderEmployeeMenu()}
<textarea
ref={inputRef}
value={currentInput}
onChange={handleInputChange}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
className="absolute inset-0 w-full h-full opacity-0 resize-none outline-none"
placeholder=""
/>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileUpload}
className="hidden"
accept=".pdf,.doc,.docx,.txt,.png,.jpg,.jpeg"
/>
</div>
);
};
return (
<div className="w-[1440px] h-[810px] p-4 bg-Neutrals-NeutralSlate200 inline-flex justify-start items-start overflow-hidden">
<div className="flex-1 self-stretch rounded-3xl shadow-[0px_0px_15px_0px_rgba(0,0,0,0.08)] flex justify-between items-start overflow-hidden">
<Sidebar companyName="Zitlac Media" />
<div className="flex-1 self-stretch py-6 bg-Neutrals-NeutralSlate0 inline-flex flex-col justify-center items-center gap-2.5">
{renderChatInterface()}
</div>
</div>
</div>
);
};
export default ChatNew;