iris_backend / src /pages /ApplicantMessages.jsx
sameer2026's picture
Fix messaging UI alignment, unsend functionality, and applicant notification bugs
b66d14f
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { supabase } from '../supabaseClient';
import ApplicantLayout from '../components/ApplicantLayout';
import PlaceholderContent from '../components/PlaceholderContent';
import { ChatIcon, SearchIcon } from '../components/Icons';
// --- Inline UI Components for Chat ---
const SendIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
);
const Avatar = ({ name }) => (
<div style={{
width: 45, height: 45, borderRadius: '50%',
background: 'linear-gradient(135deg, #374151, #1f2937)',
border: '2px solid rgba(255,255,255,0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 'bold', color: 'white', flexShrink: 0
}}>
{name ? name[0].toUpperCase() : 'U'}
</div>
);
const glassStyle = { background: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(16px)', border: '1px solid rgba(255, 255, 255, 0.08)', borderRadius: '1.25rem', display: 'flex', flexDirection: 'column', overflow: 'hidden' };
const inputStyle = { width: '100%', padding: '0.8rem', background: 'transparent', border: 'none', color: 'white', outline: 'none', fontSize: '0.95rem' };
export default function ApplicantMessages({ onNavigate }) {
const [threads, setThreads] = useState([]);
const [selected, setSelected] = useState(null);
const [text, setText] = useState('');
const [search, setSearch] = useState('');
const [userId, setUserId] = useState(null);
const [loading, setLoading] = useState(true);
const scrollRef = useRef(null);
// Auto-scroll to bottom of conversation
useEffect(() => {
scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [selected?.msgs]);
// ✅ FETCH MESSAGES AND NAMES
const fetchMsgs = async (uid) => {
try {
console.log("Fetching messages for user:", uid);
const { data: messages, error } = await supabase
.from('messages')
.select('*')
.or(`receiver_id.eq.${uid},sender_id.eq.${uid}`)
.order('created_at', { ascending: true });
if (error) throw error;
const threadMap = {};
messages.forEach(m => {
let isMe = m.sender_id === uid;
let otherId = isMe ? m.receiver_id : m.sender_id;
// --- FIX FOR AUTOMATED MESSAGES ---
// If the applicant sent it to themselves, it's a thread used for system messages.
if (m.sender_id === m.receiver_id && m.sender_id === uid) {
otherId = uid; // Keep a valid UUID so replies work
// Only force the automated welcome message to the left side
if (m.content && m.content.startsWith("Hello, Thank you for applying")) {
isMe = false;
} else {
isMe = true; // Applicant's manual replies stay on the right
}
}
if (!threadMap[otherId]) {
threadMap[otherId] = {
id: otherId,
name: 'Admin / HR',
subj: 'Application Update',
last: '',
unread: false,
time: m.created_at,
msgs: [],
companyName: '',
companyLogo: '',
companyId: null
};
}
threadMap[otherId].msgs.push({
id: m.id,
text: m.content,
time: new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
rawTime: m.created_at,
isMe
});
threadMap[otherId].last = m.content;
threadMap[otherId].time = m.created_at;
if (!isMe && !m.is_read) threadMap[otherId].unread = true;
});
const threadList = Object.values(threadMap).sort((a, b) => new Date(b.time) - new Date(a.time));
// ✅ Fetch Admin Names and Company Info from 'user_roles' table
const otherUserIds = threadList.map(t => t.id);
if (otherUserIds.length > 0) {
const { data: rolesData } = await supabase
.from('user_roles')
.select('user_id, name, company_id')
.in('user_id', otherUserIds);
console.log("Roles Data:", rolesData);
if (rolesData && rolesData.length > 0) {
rolesData.forEach(role => {
const thread = threadList.find(t => t.id === role.user_id);
if (thread && role.name) thread.name = role.name;
if (thread) thread.companyId = role.company_id;
});
// ✅ Fetch Company Details including logo from storage
const companyIds = [...new Set(rolesData.map(r => r.company_id).filter(Boolean))];
console.log("Company IDs to fetch:", companyIds);
if (companyIds.length > 0) {
const { data: companiesData } = await supabase
.from('companies')
.select('id, name, logo_url')
.in('id', companyIds);
console.log("Companies Data:", companiesData);
if (companiesData) {
companiesData.forEach(company => {
threadList.forEach(thread => {
if (thread.companyId === company.id) {
thread.companyName = company.name;
thread.companyLogo = company.logo_url;
console.log(`Set company for thread ${thread.id}:`, company.name, company.logo_url);
}
});
});
}
}
} else {
console.log("No roles data found or rolesData is empty");
}
}
console.log("Final Thread List:", threadList);
setThreads(threadList);
// Refresh currently open chat window if data changed
if (selected) {
const updated = threadList.find(t => t.id === selected.id);
if (updated) setSelected(updated);
}
} catch (err) {
console.error("Message Fetch Error:", err);
} finally {
setLoading(false);
}
};
// ✅ REAL-TIME LISTENER
useEffect(() => {
let channel;
const init = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
setUserId(user.id);
await fetchMsgs(user.id);
// Create a channel to listen for any new messages where THIS user is the receiver
channel = supabase.channel('applicant_inbox')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `receiver_id=eq.${user.id}`
}, () => {
console.log("New message received from Admin!");
fetchMsgs(user.id);
})
.subscribe();
};
init();
return () => { if (channel) supabase.removeChannel(channel); };
}, []);
const markAsRead = async (t) => {
setSelected(t);
if (t.unread && userId) {
// Optimistic UI Update
setThreads(threads.map(x => x.id === t.id ? { ...x, unread: false } : x));
// Database Update
await supabase.from('messages')
.update({ is_read: true })
.eq('receiver_id', userId)
.eq('sender_id', t.id)
.eq('is_read', false);
}
};
const sendMsg = async () => {
if (!text.trim() || !selected || !userId) return;
const messageText = text.trim();
setText('');
// Send to Supabase
const { error } = await supabase.from('messages').insert([{
sender_id: userId,
receiver_id: selected.id,
content: messageText,
is_read: false
}]);
if (error) {
console.error("Send Error:", error);
} else {
fetchMsgs(userId); // Refresh to show my sent message
}
};
const unsendMsg = async (msgId) => {
if (!window.confirm("Unsend this message?")) return;
// 1. Try to delete the message
const { data, error } = await supabase.from('messages').delete().eq('id', msgId).select();
if (error) {
console.error("Unsend Error:", error);
alert("Failed to unsend message.");
return;
}
// 2. If RLS silently blocks deletion (data is empty), fallback to updating the content
if (!data || data.length === 0) {
const { error: updateError } = await supabase
.from('messages')
.update({ content: "🚫 This message was unsent" })
.eq('id', msgId);
if (updateError) {
alert("Database policies prevent unsending this message.");
return;
}
}
fetchMsgs(userId);
};
const canUnsend = (rawTime) => {
if (!rawTime) return false;
const msgTime = new Date(rawTime).getTime();
return (Date.now() - msgTime) < 5 * 60 * 1000; // 5 minutes
};
const filtered = threads.filter(t => t.name.toLowerCase().includes(search.toLowerCase()));
return (
<ApplicantLayout activePage="applicant-messages" onNavigate={onNavigate}>
<style>{`.hide-scroll::-webkit-scrollbar { width: 0px; }`}</style>
<div style={{ display: 'flex', height: 'calc(100vh - 140px)', gap: '1.5rem', paddingBottom: '1rem' }}>
{/* --- CONVERSATIONS LIST --- */}
<div style={{ ...glassStyle, width: '350px', flexShrink: 0 }}>
<div style={{ padding: '1.5rem' }}>
<h2 style={{ fontSize: '1.4rem', fontWeight: 'bold', marginBottom: '1rem' }}>Inbox</h2>
<div style={{ position: 'relative', background: 'rgba(0,0,0,0.3)', borderRadius: '0.75rem', border: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ position: 'absolute', left: 14, top: 12, color: '#64748b' }}><SearchIcon /></div>
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search..." style={{ ...inputStyle, paddingLeft: '2.5rem' }} />
</div>
</div>
<div className="hide-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 0.75rem 1rem' }}>
{loading ? (
<div style={{ padding: '2rem', textAlign: 'center', color: '#64748b' }}>Loading...</div>
) : filtered.length === 0 ? (
<div style={{ padding: '2rem', textAlign: 'center', color: '#64748b' }}>No messages found.</div>
) : (
filtered.map(t => {
const isAct = selected?.id === t.id;
return (
<motion.div
key={t.id} onClick={() => markAsRead(t)}
whileHover={{ scale: 0.98 }}
style={{
padding: '1.25rem', marginBottom: '0.5rem', borderRadius: '1rem',
cursor: 'pointer', position: 'relative',
background: isAct ? 'rgba(251, 191, 36, 0.08)' : 'transparent',
border: isAct ? '1px solid rgba(251,191,36,0.3)' : '1px solid transparent',
display: 'flex', gap: '1rem'
}}
>
{t.unread && !isAct && <div style={{ position: 'absolute', top: '1.5rem', right: '1.25rem', width: '10px', height: '10px', backgroundColor: '#FBBF24', borderRadius: '50%' }}></div>}
{/* Company Logo */}
{t.companyLogo ? (
<img
src={t.companyLogo}
alt={t.companyName}
style={{ width: 48, height: 48, borderRadius: '0.5rem', objectFit: 'cover', flexShrink: 0, border: '1px solid rgba(255,255,255,0.2)' }}
/>
) : (
<Avatar name={t.companyName || t.name} />
)}
{/* Message Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<span style={{ fontWeight: 'bold', color: isAct ? '#FCD34D' : 'white', fontSize: '0.95rem' }}>{t.companyName || t.name}</span>
<span style={{ background: 'rgba(34, 197, 94, 0.2)', color: '#22c55e', padding: '0.15rem 0.5rem', borderRadius: '0.25rem', fontSize: '0.65rem', fontWeight: '600', flexShrink: 0 }}>HR</span>
<span style={{ fontSize: '0.7rem', color: t.unread ? '#FCD34D' : '#64748b', marginLeft: 'auto', flexShrink: 0 }}>{new Date(t.time).toLocaleDateString([], { month: 'short', day: 'numeric' })}</span>
</div>
<div style={{ fontSize: '0.8rem', color: '#e2e8f0', margin: '0.2rem 0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t.subj}</div>
<div style={{ fontSize: '0.75rem', color: '#94a3b8', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t.last}</div>
</div>
</motion.div>
);
})
)}
</div>
</div>
{/* --- ACTIVE CONVERSATION --- */}
<div style={{ ...glassStyle, flex: 1 }}>
{selected ? (
<>
<div style={{ padding: '1.25rem 2rem', background: 'rgba(0,0,0,0.2)', borderBottom: '1px solid rgba(255,255,255,0.08)', display: 'flex', alignItems: 'center', gap: '1rem' }}>
{selected.companyLogo ? (
<img
src={selected.companyLogo}
alt={selected.companyName}
style={{ width: 45, height: 45, borderRadius: '0.5rem', objectFit: 'cover', border: '2px solid rgba(255,255,255,0.2)', flexShrink: 0 }}
/>
) : (
<Avatar name={selected.companyName || selected.name} />
)}
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<h3 style={{ margin: 0, color: 'white' }}>{selected.companyName || selected.name}</h3>
<span style={{ background: 'rgba(34, 197, 94, 0.2)', color: '#22c55e', padding: '0.25rem 0.75rem', borderRadius: '0.5rem', fontSize: '0.75rem', fontWeight: '600' }}>HR</span>
</div>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#10b981' }}>Active Now</p>
</div>
</div>
<div className="hide-scroll" style={{ flex: 1, padding: '2rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{selected.msgs.map(m => (
<motion.div key={m.id} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} style={{ alignSelf: m.isMe ? 'flex-end' : 'flex-start', maxWidth: '70%', position: 'relative' }}>
<div style={{
background: m.isMe ? 'linear-gradient(135deg, #FBBF24, #F59E0B)' : 'rgba(255,255,255,0.06)',
color: m.isMe ? '#020617' : 'white',
padding: '1rem 1.25rem', borderRadius: '1.25rem',
borderBottomRightRadius: m.isMe ? 4 : '1.25rem', borderBottomLeftRadius: m.isMe ? '1.25rem' : 4
}}>
{m.text}
</div>
<div style={{ display: 'flex', justifyContent: m.isMe ? 'flex-end' : 'flex-start', alignItems: 'center', gap: '8px', marginTop: '4px' }}>
<div style={{ fontSize: '0.7rem', color: '#64748b' }}>{m.time}</div>
{m.isMe && canUnsend(m.rawTime) && (
<button onClick={() => unsendMsg(m.id)} title="Unsend message (5 min window)" style={{ background: 'transparent', border: 'none', color: '#ef4444', cursor: 'pointer', padding: '0 2px', display: 'flex', alignItems: 'center' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
</button>
)}
</div>
</motion.div>
))}
<div ref={scrollRef} />
</div>
<div style={{ padding: '1.5rem', background: 'rgba(0,0,0,0.3)', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', gap: '1rem', background: 'rgba(255,255,255,0.03)', borderRadius: '1rem', padding: '0.5rem', border: '1px solid rgba(255,255,255,0.1)' }}>
<textarea
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMsg(); } }}
placeholder="Type your reply..."
style={{ ...inputStyle, resize: 'none', height: '45px', fontFamily: 'inherit' }}
/>
<button onClick={sendMsg} disabled={!text.trim()} style={{ background: text.trim() ? '#FBBF24' : 'rgba(255,255,255,0.05)', color: text.trim() ? '#020617' : '#64748b', border: 'none', borderRadius: '50%', width: 45, height: 45, cursor: text.trim() ? 'pointer' : 'not-allowed', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<SendIcon />
</button>
</div>
</div>
</>
) : (
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<PlaceholderContent title="Your Messages" message="Select an Admin to start chatting." icon={<ChatIcon />} />
</div>
)}
</div>
</div>
</ApplicantLayout>
);
}