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.
This commit is contained in:
321
frontend-web/src/components/chat/ChatBubble.jsx
Normal file
321
frontend-web/src/components/chat/ChatBubble.jsx
Normal file
@@ -0,0 +1,321 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user