Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } |