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.
321 lines
14 KiB
JavaScript
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>
|
|
</>
|
|
);
|
|
} |