Files
Krow-workspace/frontend-web/src/components/chat/ChatBubble.jsx
bwnyasse 554dc9f9e3 feat: Initialize monorepo structure and comprehensive documentation
This commit establishes the new monorepo architecture for the KROW Workforce platform.

Key changes include:
- Reorganized project into `frontend-web`, `mobile-apps`, `firebase`, `scripts`, and `secrets` directories.
- Updated `Makefile` to support the new monorepo layout and automate Base44 export integration.
- Fixed `scripts/prepare-export.js` for ES module compatibility and global component import resolution.
- Created and updated `CONTRIBUTING.md` for developer onboarding.
- Restructured, renamed, and translated all `docs/` files for clarity and consistency.
- Implemented an interactive internal launchpad with diagram viewing capabilities.
- Configured base Firebase project files (`firebase.json`, security rules).
- Updated `README.md` to reflect the new project structure and documentation overview.
2025-11-12 12:50:55 -05:00

321 lines
14 KiB
JavaScript

import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { MessageSquare, X, Send, Minimize2, Maximize2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { motion, AnimatePresence } from "framer-motion";
import { format } from "date-fns";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
export default function ChatBubble() {
const [isOpen, setIsOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [selectedConv, setSelectedConv] = useState(null);
const [messageInput, setMessageInput] = useState("");
const navigate = useNavigate();
const { data: user } = useQuery({
queryKey: ['current-user'],
queryFn: () => base44.auth.me(),
});
const { data: conversations, refetch: refetchConversations } = useQuery({
queryKey: ['conversations-bubble'],
queryFn: () => base44.entities.Conversation.list('-last_message_at', 5),
initialData: [],
refetchInterval: 10000, // Refresh every 10 seconds
});
const { data: messages, refetch: refetchMessages } = useQuery({
queryKey: ['messages-bubble', selectedConv?.id],
queryFn: () => base44.entities.Message.filter({ conversation_id: selectedConv?.id }),
initialData: [],
enabled: !!selectedConv?.id,
refetchInterval: 5000, // Refresh every 5 seconds when viewing
});
const totalUnread = conversations.reduce((sum, conv) => sum + (conv.unread_count || 0), 0);
const handleSendMessage = async () => {
if (!messageInput.trim() || !selectedConv) return;
await base44.entities.Message.create({
conversation_id: selectedConv.id,
sender_id: user.id,
sender_name: user.full_name || user.email,
sender_role: user.role || "admin",
content: messageInput.trim(),
read_by: [user.id]
});
await base44.entities.Conversation.update(selectedConv.id, {
last_message: messageInput.trim().substring(0, 100),
last_message_at: new Date().toISOString()
});
setMessageInput("");
refetchMessages();
refetchConversations();
};
const getRoleColor = (role) => {
const colors = {
client: "bg-purple-100 text-purple-700",
vendor: "bg-amber-100 text-amber-700",
staff: "bg-blue-100 text-blue-700",
admin: "bg-slate-100 text-slate-700"
};
return colors[role] || "bg-slate-100 text-slate-700";
};
return (
<>
{/* Chat Bubble Button */}
<AnimatePresence>
{!isOpen && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
className="fixed bottom-6 right-6 z-50"
>
<Button
onClick={() => setIsOpen(true)}
className="w-16 h-16 rounded-full bg-gradient-to-br from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 shadow-2xl relative"
>
<MessageSquare className="w-7 h-7 text-white" />
{totalUnread > 0 && (
<Badge className="absolute -top-2 -right-2 bg-red-500 text-white px-2 py-0.5 text-xs">
{totalUnread > 9 ? '9+' : totalUnread}
</Badge>
)}
</Button>
</motion.div>
)}
</AnimatePresence>
{/* Chat Window */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ scale: 0, opacity: 0, y: 100 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0, opacity: 0, y: 100 }}
className="fixed bottom-6 right-6 z-50"
style={{
width: isMinimized ? '350px' : '400px',
height: isMinimized ? 'auto' : '600px'
}}
>
<Card className="shadow-2xl border-2 border-slate-200 overflow-hidden h-full flex flex-col">
{/* Header */}
<CardHeader className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white rounded-full flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-[#0A39DF]" />
</div>
<div>
<CardTitle className="text-white text-base">
{selectedConv ? selectedConv.subject : 'Messages'}
</CardTitle>
{selectedConv && (
<p className="text-xs text-white/80">
{selectedConv.is_group
? `${selectedConv.participants?.length || 0} members`
: selectedConv.participants?.[1]?.name || 'Chat'}
</p>
)}
{!selectedConv && (
<p className="text-xs text-white/80">
{totalUnread > 0 ? `${totalUnread} unread` : 'Online'}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{selectedConv && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setSelectedConv(null)}
>
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setIsMinimized(!isMinimized)}
>
{isMinimized ? <Maximize2 className="w-4 h-4" /> : <Minimize2 className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setIsOpen(false)}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
{!isMinimized && (
<CardContent className="p-0 flex-1 flex flex-col">
{!selectedConv ? (
<>
{/* Conversations List */}
<ScrollArea className="flex-1">
<div className="p-4 space-y-2">
{conversations.length === 0 ? (
<div className="text-center py-12">
<MessageSquare className="w-12 h-12 mx-auto text-slate-300 mb-3" />
<p className="text-slate-500 text-sm">No conversations yet</p>
<Button
onClick={() => {
setIsOpen(false);
navigate(createPageUrl("Messages"));
}}
className="mt-4 bg-[#0A39DF] hover:bg-[#0A39DF]/90"
size="sm"
>
Start a Conversation
</Button>
</div>
) : (
conversations.map((conv) => {
const otherParticipant = conv.participants?.[1] || conv.participants?.[0] || {};
return (
<Card
key={conv.id}
className="cursor-pointer hover:bg-slate-50 transition-all border border-slate-200"
onClick={() => setSelectedConv(conv)}
>
<CardContent className="p-3">
<div className="flex items-start gap-3">
<Avatar className="w-10 h-10 flex-shrink-0">
<AvatarFallback className={conv.is_group ? "bg-purple-500 text-white" : "bg-[#0A39DF] text-white"}>
{conv.is_group ? <MessageSquare className="w-5 h-5" /> : otherParticipant.name?.charAt(0) || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<p className="font-semibold text-sm truncate">
{conv.is_group ? conv.group_name : (conv.subject || otherParticipant.name)}
</p>
{conv.unread_count > 0 && (
<Badge className="bg-red-500 text-white text-xs">
{conv.unread_count}
</Badge>
)}
</div>
<p className="text-xs text-slate-500 truncate">
{conv.last_message || "No messages yet"}
</p>
{conv.last_message_at && (
<p className="text-xs text-slate-400 mt-1">
{format(new Date(conv.last_message_at), "MMM d, h:mm a")}
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
})
)}
</div>
</ScrollArea>
{/* Quick Action */}
<div className="border-t border-slate-200 p-3 bg-slate-50">
<Button
onClick={() => {
setIsOpen(false);
navigate(createPageUrl("Messages"));
}}
className="w-full bg-[#0A39DF] hover:bg-[#0A39DF]/90"
size="sm"
>
View All Messages
</Button>
</div>
</>
) : (
<>
{/* Messages Thread */}
<ScrollArea className="flex-1 p-4">
<div className="space-y-3">
{messages.map((message) => {
const isOwnMessage = message.sender_id === user?.id || message.created_by === user?.id;
return (
<div
key={message.id}
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
>
<div className={`flex gap-2 max-w-[80%] ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
<Avatar className="w-7 h-7 flex-shrink-0">
<AvatarFallback className={`${getRoleColor(message.sender_role)} text-xs font-bold`}>
{message.sender_name?.charAt(0) || '?'}
</AvatarFallback>
</Avatar>
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
<div className={`px-3 py-2 rounded-lg ${
isOwnMessage
? 'bg-[#0A39DF] text-white'
: 'bg-slate-100 text-slate-900'
}`}>
<p className="text-sm">{message.content}</p>
</div>
<p className="text-xs text-slate-400 mt-1">
{message.created_date && format(new Date(message.created_date), "h:mm a")}
</p>
</div>
</div>
</div>
);
})}
</div>
</ScrollArea>
{/* Message Input */}
<div className="border-t border-slate-200 p-3 bg-white">
<div className="flex gap-2">
<Input
placeholder="Type a message..."
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
className="flex-1"
/>
<Button
onClick={handleSendMessage}
disabled={!messageInput.trim()}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
size="icon"
>
<Send className="w-4 h-4" />
</Button>
</div>
</div>
</>
)}
</CardContent>
)}
</Card>
</motion.div>
)}
</AnimatePresence>
</>
);
}