changes regarding the mui to astryx tempalate based design

This commit is contained in:
2026-07-23 18:35:49 +05:30
parent ef0b14d254
commit 428e336553
33 changed files with 4679 additions and 5875 deletions

View File

@@ -1,43 +1,12 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
AppBar,
Toolbar,
IconButton,
Box,
InputBase,
Badge,
Avatar,
Typography,
Menu,
MenuItem,
Divider,
ListItemIcon,
ListItemText,
Tooltip,
Button,
Stack,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Grid,
alpha,
InputAdornment
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import SearchIcon from '@mui/icons-material/Search';
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
import ChatIcon from '@mui/icons-material/Chat';
import LogoutIcon from '@mui/icons-material/Logout';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import DoneAllIcon from '@mui/icons-material/DoneAll';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import SendIcon from '@mui/icons-material/Send';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
import { Settings, LogOut, Bell, MessageSquare, Search, Truck, AlertTriangle, ArrowRight, Send, CheckCircle2 } from 'lucide-react';
import { TopNav, TopNavHeading } from '@astryxdesign/core/TopNav';
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Avatar } from '@astryxdesign/core/Avatar';
import Button from '@/components/Button';
import Logo from '@/components/Logo';
import { getStaff, getHubContext, clearSession } from '@/auth/session';
import {
@@ -49,18 +18,16 @@ import {
markConversationRead
} from '@/api/hub';
const RED = '#C01227';
const RED = 'var(--color-brand)';
// Map a notification `type` to an icon component (real API sends type, not an icon).
const NOTIF_ICON = {
exception: WarningAmberIcon,
inbound: LocalShippingOutlinedIcon,
dispatch: LocalShippingOutlinedIcon,
warning: WarningAmberIcon,
alert: NotificationsActiveIcon
exception: AlertTriangle,
inbound: Truck,
dispatch: Truck,
warning: AlertTriangle,
alert: Bell
};
// Build initials from a display name — falls back to a sensible default.
const toInitials = (name) =>
(name || '')
.split(' ')
@@ -70,8 +37,6 @@ const toInitials = (name) =>
.join('')
.toUpperCase() || 'HB';
// Normalise a conversation summary from GET /hub/messages. Be tolerant of a few
// backend field-name variants so the list still renders if a key is named differently.
const toConversation = (c) => {
const name = c.name || c.milername || c.displayname || `Miler ${c.mileruserid ?? c.id}`;
return {
@@ -84,14 +49,13 @@ const toConversation = (c) => {
};
};
// Normalise one message in a thread from GET /hub/messages/:id.
const toMessage = (m) => ({
sender: m.sender === 'me' ? 'me' : 'them',
text: m.text ?? m.body ?? m.message ?? '',
time: m.time ?? m.createdat ?? m.sentat ?? ''
});
export default function Header({ onToggle }) {
export default function Header({ isSidebarCollapsed }) {
const navigate = useNavigate();
const staff = getStaff();
const hub = getHubContext();
@@ -103,14 +67,9 @@ export default function Header({ onToggle }) {
navigate('/login');
};
const [account, setAccount] = useState(null);
const [notifAnchor, setNotifAnchor] = useState(null);
const [msgAnchor, setMsgAnchor] = useState(null);
// Dialog State
const [selectedNotif, setSelectedNotif] = useState(null);
const [conversations, setConversations] = useState([]);
const [activeChat, setActiveChat] = useState(null); // { id, name, initials, messages: [] }
const [activeChat, setActiveChat] = useState(null);
const [chatLoading, setChatLoading] = useState(false);
const [sending, setSending] = useState(false);
const [typedMessage, setTypedMessage] = useState('');
@@ -122,7 +81,6 @@ export default function Header({ onToggle }) {
const unread = notifications.filter((n) => !n.read).length;
// Load real notifications from the API (map `type` → an icon component).
const loadNotifications = useCallback(async () => {
try {
const res = await getNotifications();
@@ -133,43 +91,39 @@ export default function Header({ onToggle }) {
time: n.time,
read: Boolean(n.read),
type: n.type,
icon: NOTIF_ICON[n.type] || NotificationsNoneIcon
icon: NOTIF_ICON[n.type] || Bell,
desc: n.desc,
stats: n.stats || [],
to: n.to,
actionText: n.actionText
}))
);
} catch {
// Non-fatal: leave the bell empty if it can't load.
setNotifications([]);
}
}, []);
useEffect(() => {
loadNotifications();
const t = setInterval(loadNotifications, 60000); // refresh every 60s
const t = setInterval(loadNotifications, 60000);
return () => clearInterval(t);
}, [loadNotifications]);
// Load conversations (one per miler at the hub) for the messages dropdown.
const loadConversations = useCallback(async () => {
try {
const res = await getConversations();
setConversations((res?.data || []).map(toConversation));
} catch {
// Non-fatal: leave the messages list empty if it can't load.
setConversations([]);
}
}, []);
useEffect(() => {
loadConversations();
const t = setInterval(loadConversations, 60000); // refresh every 60s
const t = setInterval(loadConversations, 60000);
return () => clearInterval(t);
}, [loadConversations]);
const openNotif = (e) => { setNotifAnchor(e.currentTarget); loadNotifications(); };
const closeNotif = () => setNotifAnchor(null);
const openMessages = (e) => { setMsgAnchor(e.currentTarget); loadConversations(); };
const markAllRead = async () => {
const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id);
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
@@ -178,18 +132,14 @@ export default function Header({ onToggle }) {
const onNotifClick = async (n) => {
setNotifications((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x)));
closeNotif();
if (n.desc || n.stats) setSelectedNotif(n);
else if (n.to) navigate(n.to);
try {
await markNotificationRead(n.id);
} catch {
/* best effort */
}
} catch {}
};
// Open a conversation: show the header immediately, then load the thread and
// mark the other party's messages as read (which clears the unread badge).
const onMessageClick = async (conv) => {
setMsgAnchor(null);
setTypedMessage('');
setActiveChat({ id: conv.id, name: conv.name, initials: conv.initials, messages: [] });
setChatLoading(true);
@@ -207,35 +157,30 @@ export default function Header({ onToggle }) {
setConversations((prev) => prev.map((c) => (c.id === conv.id ? { ...c, unread: 0 } : c)));
}
} catch {
// Leave the (empty) thread open; the header still shows who it's with.
} finally {
setChatLoading(false);
}
};
const closeChat = () => { setActiveChat(null); setTypedMessage(''); };
const closeChat = () => { setActiveChat(null); setTypedMessage(''); };
const handleSendMessage = async () => {
const text = typedMessage.trim();
if (!text || !activeChat || sending) return;
setSending(true);
// Optimistically append; reconcile with the server's stored copy on success.
const now = new Date();
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
setActiveChat((c) => ({ ...c, messages: [...c.messages, { sender: 'me', text, time: timeStr }] }));
setTypedMessage('');
try {
await sendMessage(activeChat.id, text);
// Refresh the thread so the persisted message (and its real timestamp) shows.
const res = await getConversation(activeChat.id);
const thread = res?.data || {};
setActiveChat((c) => c && { ...c, messages: (thread.messages || thread.chat || []).map(toMessage) });
// Keep the dropdown preview in sync.
setConversations((prev) =>
prev.map((c) => (c.id === activeChat.id ? { ...c, lastMessage: text, time: timeStr } : c))
);
} catch {
// On failure, drop the optimistic bubble and restore the draft to retry.
setActiveChat((c) => c && { ...c, messages: c.messages.filter((m) => !(m.sender === 'me' && m.text === text && m.time === timeStr)) });
setTypedMessage(text);
} finally {
@@ -250,336 +195,118 @@ export default function Header({ onToggle }) {
};
return (
<AppBar
position="fixed"
elevation={0}
sx={{
bgcolor: '#FFFFFF',
color: 'text.primary',
zIndex: (t) => t.zIndex.drawer + 1,
borderBottom: '1px solid',
borderColor: 'grey.200'
}}
>
<Toolbar sx={{ minHeight: 64, px: { xs: 1.5, sm: 2.5 }, gap: 1 }}>
<IconButton color="inherit" edge="start" onClick={onToggle} sx={{ mr: 0.5 }}>
<MenuIcon />
</IconButton>
{/* Brand wordmark — left side */}
<Box
onClick={() => navigate('/dashboard')}
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
>
<Logo height={22} />
</Box>
<Box sx={{ flexGrow: 1 }} />
{/* Search */}
<Box
component="form"
onSubmit={submitSearch}
sx={{
display: { xs: 'none', sm: 'flex' },
alignItems: 'center',
bgcolor: 'grey.100',
borderRadius: 2,
px: 1.5,
py: 0.5,
width: { sm: 240, md: 320 },
border: '1px solid',
borderColor: 'grey.200',
'&:hover': { bgcolor: 'grey.200' }
}}
>
<SearchIcon sx={{ fontSize: 20, mr: 1, color: 'text.secondary' }} />
<InputBase
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Scan package, check destination…"
sx={{ fontSize: '0.875rem', flex: 1 }}
inputProps={{ 'aria-label': 'search' }}
<>
<TopNav
label="Main navigation"
style={{
backgroundColor: '#ffffff',
borderBottom: '1px solid rgba(5, 54, 89, 0.08)',
boxShadow: '0 1px 3px rgba(15, 23, 42, 0.04)'
}}
heading={
<TopNavHeading
logo={<Logo compact={isSidebarCollapsed} size={isSidebarCollapsed ? 36 : 32} height={28} />}
headingHref="/dashboard"
/>
</Box>
}
endContent={
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', flex: 1, justifyContent: 'flex-end' }}>
<DropdownMenu
button={{ variant: 'ghost', icon: <div style={{ position: 'relative' }}><MessageSquare size={20} />{unreadMessages > 0 && <span style={{ position: 'absolute', top: -4, right: -4, background: RED, color: '#fff', fontSize: '10px', borderRadius: '10px', padding: '0 4px' }}>{unreadMessages}</span>}</div> }}
items={[
{ label: 'Messages', type: 'label' },
...conversations.map(m => ({
label: m.name,
description: m.lastMessage,
onClick: () => onMessageClick(m)
}))
]}
/>
<DropdownMenu
button={{ variant: 'ghost', icon: <div style={{ position: 'relative' }}><Bell size={20} />{unread > 0 && <span style={{ position: 'absolute', top: -4, right: -4, background: RED, color: '#fff', fontSize: '10px', borderRadius: '10px', padding: '0 4px' }}>{unread}</span>}</div> }}
items={[
{ label: 'Notifications', type: 'label' },
{ label: 'Mark all read', icon: <CheckCircle2 size={16} />, onClick: markAllRead, disabled: unread === 0 },
{ type: 'divider' },
...(notifications.length === 0 ? [{ label: 'No notifications', disabled: true }] : notifications.map((n, i) => ({
label: n.title + '\u200B'.repeat(i),
description: n.time,
icon: n.icon ? (() => { const Icon = n.icon; return <Icon size={16} />; })() : undefined,
onClick: () => onNotifClick(n)
})))
]}
/>
<Tooltip title="Messages">
<IconButton color="inherit" onClick={openMessages}>
<Badge badgeContent={unreadMessages} color="error">
<ChatIcon />
</Badge>
</IconButton>
</Tooltip>
<Tooltip title="Notifications">
<IconButton color="inherit" onClick={openNotif}>
<Badge badgeContent={unread} color="error">
<NotificationsNoneIcon />
</Badge>
</IconButton>
</Tooltip>
<DropdownMenu
button={{
variant: 'ghost',
size: 'lg',
icon: <Avatar name={staffName} fallback={toInitials(staffName)} size="sm" />,
label: staffName
}}
items={[
{ label: 'Settings', icon: <Settings size={16} />, onClick: () => navigate('/hub-settings') },
{ type: 'divider' },
{ label: 'Logout', icon: <LogOut size={16} />, onClick: handleLogout }
]}
/>
</div>
}
/>
<Box
onClick={(e) => setAccount(e.currentTarget)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
ml: 0.5,
cursor: 'pointer',
py: 0.5,
px: 1,
borderRadius: 2,
'&:hover': { bgcolor: 'grey.100' }
}}
>
<Avatar sx={{ width: 34, height: 34, bgcolor: RED, color: '#fff', fontWeight: 700 }}>{toInitials(staffName)}</Avatar>
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{staffName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{hubName}
</Typography>
</Box>
</Box>
{/* Notifications dropdown */}
<Menu
anchorEl={notifAnchor}
open={Boolean(notifAnchor)}
onClose={closeNotif}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
PaperProps={{ sx: { mt: 1, width: 360, maxWidth: '90vw' } }}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Notifications
</Typography>
<Button size="small" startIcon={<DoneAllIcon fontSize="small" />} onClick={markAllRead} disabled={unread === 0}>
Mark all read
</Button>
</Stack>
<Divider />
{notifications.length === 0 && (
<MenuItem disabled>
<ListItemText primary="No notifications" />
</MenuItem>
)}
{notifications.map((n) => {
const Icon = n.icon;
return (
<MenuItem key={n.id} onClick={() => onNotifClick(n)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
<ListItemIcon sx={{ mt: 0.25 }}>
<Avatar sx={{ width: 34, height: 34, bgcolor: n.read ? 'grey.200' : alpha(RED, 0.12), color: RED }}>
<Icon fontSize="small" />
</Avatar>
</ListItemIcon>
<ListItemText
primary={n.title}
secondary={n.time}
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: n.read ? 500 : 700 }}
secondaryTypographyProps={{ fontSize: '0.75rem' }}
/>
{!n.read && <Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: RED, mt: 1, ml: 0.5 }} />}
</MenuItem>
);
})}
</Menu>
{/* Messages dropdown */}
<Menu
anchorEl={msgAnchor}
open={Boolean(msgAnchor)}
onClose={() => setMsgAnchor(null)}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
PaperProps={{ sx: { mt: 1, width: 340, maxWidth: '90vw' } }}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700, px: 2, py: 1.25 }}>
Messages
</Typography>
<Divider />
{conversations.length === 0 && (
<MenuItem disabled>
<ListItemText primary="No messages" />
</MenuItem>
)}
{conversations.map((m) => (
<MenuItem key={m.id} onClick={() => onMessageClick(m)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
<ListItemIcon sx={{ mt: 0.25 }}>
<Avatar sx={{ width: 34, height: 34, bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, fontSize: '0.8rem' }}>
{m.initials}
</Avatar>
</ListItemIcon>
<ListItemText
primary={m.name}
secondary={m.lastMessage || 'No messages yet'}
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: m.unread > 0 ? 700 : 600 }}
secondaryTypographyProps={{ fontSize: '0.8rem', noWrap: true }}
/>
<Stack alignItems="flex-end" sx={{ ml: 1, flexShrink: 0 }}>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
{m.time}
</Typography>
{m.unread > 0 && (
<Badge badgeContent={m.unread} color="error" sx={{ mt: 1, mr: 0.75 }} />
)}
</Stack>
</MenuItem>
))}
</Menu>
{/* Account dropdown */}
<Menu
anchorEl={account}
open={Boolean(account)}
onClose={() => setAccount(null)}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
PaperProps={{ sx: { mt: 1, minWidth: 200 } }}
>
<MenuItem onClick={() => { setAccount(null); handleLogout(); }} sx={{ color: 'error.main' }}>
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
Logout
</MenuItem>
</Menu>
</Toolbar>
{/* High Fidelity Notification Detail Dialog */}
<Dialog open={Boolean(selectedNotif)} onClose={() => setSelectedNotif(null)} fullWidth maxWidth="sm">
{selectedNotif && (
<>
<DialogTitle sx={{ fontWeight: 700, bgcolor: 'grey.50', py: 2 }}>
{selectedNotif.title}
</DialogTitle>
<Divider />
<DialogContent sx={{ py: 3 }}>
<Typography variant="body1" sx={{ mb: 3 }}>
{selectedNotif.desc}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'text.secondary' }}>
Operational Details
</Typography>
<Grid container spacing={2}>
{selectedNotif.stats.map((stat, index) => (
<Grid size={{ xs: 6 }} key={index}>
<Box sx={{ p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'grey.200' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontWeight: 600 }}>
{stat.label}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mt: 0.5 }}>
{stat.value}
</Typography>
</Box>
</Grid>
))}
</Grid>
</DialogContent>
<Divider />
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setSelectedNotif(null)}>Dismiss</Button>
<Button
variant="contained"
endIcon={<ArrowForwardIcon fontSize="small" />}
onClick={() => {
setSelectedNotif(null);
navigate(selectedNotif.to);
}}
>
{selectedNotif.actionText}
{selectedNotif && (
<dialog open style={{ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 1000, background: '#fff', padding: '24px', borderRadius: '8px', border: '1px solid #e2e8f0', boxShadow: '0 10px 25px rgba(0,0,0,0.1)', maxWidth: '500px', width: '100%' }}>
<h2 style={{ margin: '0 0 16px 0', fontSize: '1.25rem' }}>{selectedNotif.title}</h2>
<p style={{ margin: '0 0 16px 0', color: '#475569' }}>{selectedNotif.desc}</p>
<div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
{selectedNotif.stats.map((s, i) => (
<div key={i} style={{ background: '#f8fafc', padding: '12px', borderRadius: '8px', flex: 1 }}>
<div style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 'bold' }}>{s.label}</div>
<div style={{ fontSize: '1.125rem', fontWeight: 'bold', marginTop: '4px' }}>{s.value}</div>
</div>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<Button variant="ghost" onClick={() => setSelectedNotif(null)}>Dismiss</Button>
{selectedNotif.actionText && (
<Button onClick={() => { setSelectedNotif(null); navigate(selectedNotif.to); }}>
{selectedNotif.actionText} <ArrowRight size={16} />
</Button>
</DialogActions>
</>
)}
</Dialog>
)}
</div>
</dialog>
)}
{/* High Fidelity Chat Message Dialog */}
<Dialog open={Boolean(activeChat)} onClose={closeChat} fullWidth maxWidth="xs">
{activeChat && (
<>
<DialogTitle sx={{ fontWeight: 700, py: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Avatar sx={{ bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, width: 34, height: 34 }}>
{activeChat.initials}
</Avatar>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
{activeChat.name}
</Typography>
</DialogTitle>
<Divider />
<DialogContent sx={{ p: 2, bgcolor: 'grey.50', height: 280, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<Stack spacing={2} sx={{ overflowY: 'auto', pr: 0.5, flexGrow: 1, mb: 2 }}>
{chatLoading && activeChat.messages.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}>
Loading
</Typography>
)}
{!chatLoading && activeChat.messages.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}>
No messages yet. Say hello.
</Typography>
)}
{activeChat.messages.map((msg, idx) => (
<Box
key={idx}
sx={{
alignSelf: msg.sender === 'me' ? 'flex-end' : 'flex-start',
maxWidth: '80%'
}}
>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: msg.sender === 'me' ? RED : '#FFFFFF',
color: msg.sender === 'me' ? '#FFFFFF' : 'text.primary',
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
border: msg.sender === 'me' ? 'none' : '1px solid',
borderColor: 'grey.200'
}}
>
<Typography variant="body1">
{msg.text}
</Typography>
</Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: 'block',
mt: 0.5,
textAlign: msg.sender === 'me' ? 'right' : 'left'
}}
>
{msg.time}
</Typography>
</Box>
))}
</Stack>
</DialogContent>
<Divider />
<DialogActions sx={{ p: 1.5 }}>
<TextField
fullWidth
size="small"
placeholder="Type your message..."
value={typedMessage}
onChange={(e) => setTypedMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={handleSendMessage} size="small" color="primary" disabled={sending || !typedMessage.trim()}>
<SendIcon fontSize="small" />
</IconButton>
</InputAdornment>
)
}}
/>
</DialogActions>
</>
)}
</Dialog>
</AppBar>
{activeChat && (
<dialog open style={{ position: 'fixed', bottom: '24px', right: '24px', zIndex: 1000, background: '#fff', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 10px 25px rgba(0,0,0,0.1)', width: '360px', padding: 0, overflow: 'hidden' }}>
<div style={{ padding: '16px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: '12px', background: '#f8fafc' }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: RED, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold' }}>{activeChat.initials}</div>
<h3 style={{ margin: 0, flex: 1 }}>{activeChat.name}</h3>
<Button variant="ghost" size="icon" onClick={closeChat}>×</Button>
</div>
<div style={{ height: '300px', overflowY: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', background: '#fff' }}>
{chatLoading ? (
<div style={{ textAlign: 'center', color: '#94a3b8', margin: 'auto' }}>Loading...</div>
) : activeChat.messages.length === 0 ? (
<div style={{ textAlign: 'center', color: '#94a3b8', margin: 'auto' }}>No messages yet.</div>
) : (
activeChat.messages.map((m, i) => (
<div key={i} style={{ alignSelf: m.sender === 'me' ? 'flex-end' : 'flex-start', maxWidth: '80%' }}>
<div style={{ background: m.sender === 'me' ? RED : '#f1f5f9', color: m.sender === 'me' ? '#fff' : '#0f172a', padding: '8px 12px', borderRadius: '12px', borderBottomRightRadius: m.sender === 'me' ? 0 : '12px', borderBottomLeftRadius: m.sender === 'them' ? 0 : '12px' }}>
{m.text}
</div>
<div style={{ fontSize: '0.65rem', color: '#94a3b8', textAlign: m.sender === 'me' ? 'right' : 'left', marginTop: '4px' }}>{m.time}</div>
</div>
))
)}
</div>
<div style={{ padding: '12px', borderTop: '1px solid #e2e8f0', background: '#fff', display: 'flex', gap: '8px' }}>
<TextInput style={{ flex: 1 }} value={typedMessage} onChange={(e) => setTypedMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()} placeholder="Type a message..." />
<Button size="icon" onClick={handleSendMessage} disabled={!typedMessage.trim() || sending}><Send size={16} /></Button>
</div>
</dialog>
)}
</>
);
}