iris_backend / src /components /ApplicantLayout.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 {
LogoutIcon, BriefcaseIcon, UserCircleIcon, ChatIcon,
CalendarIcon, AtsCheckerIcon
} from './Icons';
const BellIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
);
export default function ApplicantLayout({ children, activePage, onNavigate }) {
const [userName, setUserName] = useState(() => localStorage.getItem('applicant_name') || '');
const [notifications, setNotifications] = useState([]);
const [showNotifications, setShowNotifications] = useState(false);
const [hasSeenNotifs, setHasSeenNotifs] = useState(false);
const notifRef = useRef(null);
const [unreadMessages, setUnreadMessages] = useState(0);
const [unreadMessagesList, setUnreadMessagesList] = useState([]);
// ⭐ NEW: recent jobs notifications
const [newJobs, setNewJobs] = useState([]);
// ⭐ NEW: toast notification state
const [toastNotif, setToastNotif] = useState(null);
const triggerToast = (title, message, icon) => {
setToastNotif({ id: Date.now(), title, message, icon });
setTimeout(() => setToastNotif(null), 5000); // Hide after 5 seconds
};
useEffect(() => {
function handleClickOutside(event) {
if (notifRef.current && !notifRef.current.contains(event.target)) {
setShowNotifications(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [notifRef]);
useEffect(() => {
const fetchUserName = async () => {
try {
const { data: { user } } = await supabase.auth.getUser();
if (user) {
const { data: profile } = await supabase
.from('profiles')
.select('full_name')
.eq('id', user.id)
.maybeSingle();
if (profile && profile.full_name) {
const firstName = profile.full_name.split(' ')[0];
setUserName(firstName);
localStorage.setItem('applicant_name', firstName);
}
}
} catch (error) {
console.error("Error fetching name:", error);
}
};
fetchUserName();
}, []);
// ⭐ NEW: fetch unread messages count and details
useEffect(() => {
const fetchUnreadMessages = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { data: messages } = await supabase
.from("messages")
.select("id, sender_id, content, created_at")
.eq("receiver_id", user.id)
.eq("is_read", false)
.order("created_at", { ascending: false });
if (messages && messages.length > 0) {
setUnreadMessages(messages.length);
// Fetch sender names from user_roles/companies
const senderIds = [...new Set(messages.map(m => m.sender_id))];
const { data: rolesData } = await supabase
.from("user_roles")
.select("user_id, name, company_id")
.in("user_id", senderIds);
let companiesData = [];
if (rolesData) {
const companyIds = [...new Set(rolesData.map(r => r.company_id).filter(Boolean))];
if (companyIds.length > 0) {
const { data: comp } = await supabase.from("companies").select("id, name").in("id", companyIds);
if (comp) companiesData = comp;
}
}
const formattedMessages = messages.map(msg => {
let senderName = 'Admin / HR';
const role = rolesData?.find(r => r.user_id === msg.sender_id);
if (role) {
senderName = role.name || 'HR';
if (role.company_id) {
const comp = companiesData.find(c => c.id === role.company_id);
if (comp) senderName = comp.name;
}
}
return {
id: msg.id,
senderName,
content: msg.content,
timestamp: new Date(msg.created_at).toLocaleDateString() + ' • ' + new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
};
});
setUnreadMessagesList(formattedMessages);
}
};
fetchUnreadMessages();
const fetchRecentJobs = async () => {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const { data: recentJobs } = await supabase
.from('jobs')
.select('id, title, company_id, created_at, companies ( name )')
.eq('status', 'Active')
.gte('created_at', oneDayAgo)
.order('created_at', { ascending: false })
.limit(3);
if (recentJobs) {
const jobNotifs = recentJobs.map(job => ({
id: `job-${job.id}`,
title: 'New Job Posted',
text: `${job.companies?.name || 'A company'} is hiring: ${job.title}`,
time: new Date(job.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
color: '#8b5cf6' // Purple color for jobs
}));
setNewJobs(jobNotifs);
}
};
fetchRecentJobs();
}, []);
// ⭐ NEW: realtime update for new messages
useEffect(() => {
let channel, jobsChannel, appsChannel;
const initRealtime = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
channel = supabase
.channel("messages-badge")
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "messages", filter: `receiver_id=eq.${user.id}` },
async (payload) => {
console.log("=== MESSAGES PAYLOAD ===", payload);
// Ignore applicant's own manual replies to the system thread
if (payload.new.sender_id === user.id) {
if (!payload.new.content || !payload.new.content.startsWith("Hello, Thank you for applying")) {
return;
}
}
// Fetch the sender's name from user_roles/companies
let senderName = 'Admin / HR';
const { data: roleData } = await supabase
.from("user_roles")
.select("name, company_id")
.eq("user_id", payload.new.sender_id)
.maybeSingle();
if (roleData) {
senderName = roleData.name || 'HR';
if (roleData.company_id) {
const { data: company } = await supabase.from('companies').select('name').eq('id', roleData.company_id).maybeSingle();
if (company) senderName = company.name;
}
}
const newMsg = {
id: payload.new.id,
senderName,
content: payload.new.content,
timestamp: new Date(payload.new.created_at).toLocaleDateString() + ' • ' + new Date(payload.new.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
};
setUnreadMessagesList(prev => [newMsg, ...prev]);
setUnreadMessages(prev => prev + 1);
// Trigger realtime toast popup
triggerToast(
'New Message Received',
`${senderName} sent you a message`,
'📨'
);
}
)
.subscribe();
const jobsChannel = supabase
.channel("jobs-badge")
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "jobs", filter: "status=eq.Active" },
async (payload) => {
console.log("=== JOBS PAYLOAD ===", payload);
let companyName = 'A company';
if (payload.new.company_id) {
const { data: comp } = await supabase
.from("companies")
.select("name")
.eq("id", payload.new.company_id)
.maybeSingle();
if (comp) companyName = comp.name;
}
const newJobNotif = {
id: `job-${payload.new.id}`,
title: 'New Job Posted',
text: `${companyName} is hiring: ${payload.new.title}`,
time: new Date(payload.new.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
color: '#8b5cf6'
};
setNewJobs(prev => [newJobNotif, ...prev]);
// Trigger realtime toast popup
triggerToast(
'New Job Posted',
`${companyName} is hiring: ${payload.new.title}`,
'💼'
);
}
)
.subscribe();
const appsChannel = supabase
.channel("interviews-badge-applicant") // unique channel name
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "interviews" },
async (payload) => {
console.log("=== INTERVIEWS PAYLOAD ===", payload);
const { data: { user } } = await supabase.auth.getUser();
if (!user || !payload.new) return;
// Interviews don't have user_id on them directly. They have application_id.
// We must fetch the application to verify if it belongs to this user.
const { data: appData } = await supabase
.from('applications')
.select('user_id, job_id')
.eq('id', payload.new.application_id)
.maybeSingle();
if (!appData || appData.user_id !== user.id) {
return; // Not this user's interview
}
// For an insert OR an update where status is Scheduled
if (payload.new.status === 'Scheduled') {
// If it's an UPDATE, optionally ensure it actually CHANGED to Scheduled
if (payload.eventType === 'UPDATE') {
if (payload.old && payload.old.status === 'Scheduled') {
return;
}
}
console.log(">>> TRIGGERING INTERVIEW NOTIFICATION! <<<");
const { data: job } = await supabase
.from('jobs')
.select('title')
.eq('id', appData.job_id)
.maybeSingle();
const jobTitle = job?.title || 'a job';
let timeLabel = '';
if (payload.new.scheduled_time) {
const dateObj = new Date(payload.new.scheduled_time);
timeLabel = ` on ${dateObj.toLocaleDateString()} at ${dateObj.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
}
triggerToast(
'Interview Scheduled! 📅',
`You have been invited to interview for ${jobTitle}${timeLabel}`,
'🎉'
);
setHasSeenNotifs(false);
const newNotif = {
id: Date.now(),
title: `Interview Scheduled`,
text: `You have an upcoming interview for ${jobTitle}.`,
time: new Date().toISOString(),
color: '#3B82F6'
};
setNotifications(prev => [newNotif, ...prev]);
}
}
)
.subscribe();
};
initRealtime();
return () => {
if (channel) supabase.removeChannel(channel);
if (jobsChannel) supabase.removeChannel(jobsChannel);
if (appsChannel) supabase.removeChannel(appsChannel);
};
}, []);
useEffect(() => {
const fetchNotifications = async () => {
if (activePage === 'applicant-profile') {
try {
const { data: { user } } = await supabase.auth.getUser();
if (user) {
const { data: apps } = await supabase
.from('applications')
.select('id, status, jobs(title), updated_at')
.eq('user_id', user.id)
.in('status', ['Accepted', 'Rejected', 'Interviewing'])
.order('updated_at', { ascending: false })
.limit(5);
if (apps) {
const notifs = apps.map(app => ({
id: app.id,
title: `Application ${app.status}`,
text: `Your application for ${app.jobs?.title} is now ${app.status}.`,
time: app.updated_at,
color: app.status === 'Accepted' ? '#10b981' : app.status === 'Rejected' ? '#ef4444' : '#FBBF24'
}));
setNotifications(notifs);
}
}
} catch (error) {
console.error("Error fetching notifications:", error);
}
}
};
fetchNotifications();
}, [activePage]);
// ⭐ NEW: specific timing interview notifications
useEffect(() => {
let interviewInterval;
const checkInterviews = async () => {
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { data: ints } = await supabase
.from('interviews')
.select('id, scheduled_time, created_at, applications!inner( jobs(title) )')
.eq('applications.user_id', user.id)
.eq('status', 'Scheduled');
if (ints && ints.length > 0) {
const now = new Date();
ints.forEach(intObj => {
if (!intObj.scheduled_time) return;
const interviewDate = new Date(intObj.scheduled_time);
if (isNaN(interviewDate.getTime())) return;
const diffMs = interviewDate - now;
const diffHours = diffMs / (1000 * 60 * 60);
const ONE_WEEK = 7 * 24;
const TWO_DAYS = 2 * 24;
const TWENTY_FOUR_HOURS = 24;
const FIVE_HOURS = 5;
const windowHours = 0.5;
let triggered = false;
let timeLabel = '';
if (diffHours > 0) {
if (Math.abs(diffHours - ONE_WEEK) <= windowHours) {
triggered = true;
timeLabel = 'in 1 week';
} else if (Math.abs(diffHours - TWO_DAYS) <= windowHours) {
triggered = true;
timeLabel = 'in 2 days';
} else if (Math.abs(diffHours - TWENTY_FOUR_HOURS) <= windowHours) {
triggered = true;
timeLabel = 'in 24 hours';
} else if (Math.abs(diffHours - FIVE_HOURS) <= windowHours) {
triggered = true;
timeLabel = 'in 5 hours';
}
}
// Just scheduled within last 30 minutes
const updatedDate = new Date(intObj.created_at);
const updatedDiffHours = (now - updatedDate) / (1000 * 60 * 60);
if (updatedDiffHours <= windowHours) {
triggered = true;
timeLabel = 'recently scheduled';
}
if (triggered) {
const jobTitle = intObj.applications?.jobs?.title || 'a job';
triggerToast(
'Interview Reminder! 📅',
`You have an interview ${timeLabel} for: ${jobTitle}`,
'⏰'
);
}
});
}
} catch (error) {
console.error("Error checking interviews:", error);
}
};
// Check immediately on load
checkInterviews();
// Check every 30 minutes (30 * 60 * 1000 ms)
interviewInterval = setInterval(checkInterviews, 1800000);
return () => clearInterval(interviewInterval);
}, []);
// ⭐ NEW: mark messages read when user opens messages page
useEffect(() => {
const markMessagesRead = async () => {
if (activePage !== "applicant-messages") return;
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
await supabase
.from("messages")
.update({ is_read: true })
.eq("receiver_id", user.id);
setUnreadMessages(0);
setUnreadMessagesList([]);
};
markMessagesRead();
if (activePage === "applicant-jobs") {
setNewJobs([]); // Clear job notifications when jobs page is opened
}
}, [activePage]);
const handleLogout = async () => {
await supabase.auth.signOut();
localStorage.removeItem('applicant_name');
onNavigate('login');
};
const isActive = (key) => activePage === key;
const navItems = [
{ key: 'applicant-jobs', icon: <BriefcaseIcon />, label: 'Job Listings' },
{ key: 'applicant-profile', icon: <UserCircleIcon />, label: 'Profile' },
{ key: 'applicant-interviews', icon: <CalendarIcon />, label: 'Interviews' },
{ key: 'applicant-ats', icon: <AtsCheckerIcon />, label: 'ATS Checker' },
{ key: 'applicant-messages', icon: <ChatIcon />, label: 'Messages' },
];
return (
<div style={{ height: '100vh', width: '100%', backgroundColor: '#020617', color: 'white', fontFamily: "'Montserrat', sans-serif", padding: '2rem', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', position: 'relative' }}>
<header style={{ position: 'relative', zIndex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexShrink: 0 }}>
<h1 style={{ fontSize: '1.875rem', fontWeight: 'bold' }}>
{userName ? `Hi, ${userName} 👋` : 'Welcome 👋'}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
{activePage === 'applicant-profile' && (
<div style={{ position: 'relative' }} ref={notifRef}>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => {
setShowNotifications(!showNotifications);
if (!showNotifications) setHasSeenNotifs(true);
}}
style={{
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '50%',
width: '45px', height: '45px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#FCD34D', cursor: 'pointer', position: 'relative',
padding: 0
}}
>
<BellIcon />
{!hasSeenNotifs && (notifications.length + unreadMessages + newJobs.length) > 0 && (
<span style={{
position: 'absolute',
top: '-5px',
right: '-5px',
width: '24px',
height: '24px',
backgroundColor: '#FBBF24',
borderRadius: '50%',
border: '2px solid #020617',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#1a202c',
fontSize: '0.75rem',
fontWeight: 'bold'
}}>
{notifications.length + unreadMessages + newJobs.length}
</span>
)}
</motion.button>
<AnimatePresence>
{showNotifications && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
style={{
position: 'absolute',
top: '55px',
right: '0',
width: '320px',
backgroundColor: '#1e293b',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '12px',
zIndex: 50,
maxHeight: '300px',
overflowY: 'auto'
}}
>
<div style={{ padding: '1rem', fontWeight: 'bold', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
Recent Notification ({notifications.length + unreadMessages + newJobs.length})
</div>
<div style={{ padding: '0.75rem' }}>
{notifications.length + unreadMessages + newJobs.length === 0 ? (
<div style={{ padding: '1rem', textAlign: 'center', color: '#94a3b8', fontSize: '0.85rem' }}>
No new notifications
</div>
) : (
<>
{unreadMessagesList.map(msg => (
<div key={msg.id} style={{ padding: '0.75rem', borderBottom: '1px solid rgba(255,255,255,0.1)', display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}>
<span style={{ color: '#3b82f6', fontSize: '1rem' }}>📨</span>
<div style={{ flex: 1 }}>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#e2e8f0', fontWeight: 500 }}>
{msg.senderName}
</p>
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.75rem', color: '#cbd5e1' }}>
{msg.timestamp}
</p>
</div>
</div>
))}
{notifications.map(notif => (
<div key={notif.id} style={{ padding: '0.75rem', borderBottom: '1px solid rgba(255,255,255,0.1)', display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}>
<span style={{ color: notif.color, fontSize: '1rem' }}></span>
<div style={{ flex: 1 }}>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#e2e8f0', fontWeight: 500 }}>
{notif.title}
</p>
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.75rem', color: '#cbd5e1' }}>
{notif.text}
</p>
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.7rem', color: '#64748b' }}>
{notif.time}
</p>
</div>
</div>
))}
{newJobs.map(jobNotif => (
<div key={jobNotif.id} style={{ padding: '0.75rem', borderBottom: '1px solid rgba(255,255,255,0.1)', display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}>
<span style={{ color: jobNotif.color, fontSize: '1rem' }}>💼</span>
<div style={{ flex: 1 }}>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#e2e8f0', fontWeight: 500 }}>
{jobNotif.title}
</p>
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.75rem', color: '#cbd5e1' }}>
{jobNotif.text}
</p>
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.7rem', color: '#64748b' }}>
{jobNotif.time}
</p>
</div>
</div>
))}
</>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
<motion.button
onClick={handleLogout}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
style={{ backgroundColor: '#FBBF24', color: '#1a202c', display: 'flex', alignItems: 'center', padding: '0.75rem', borderRadius: '0.5rem', fontWeight: 'bold', cursor: 'pointer', border: 'none' }}
>
<LogoutIcon />
Logout
</motion.button>
</div>
</header>
<div style={{ display: 'flex', justifyContent: 'center', width: '100%', flexShrink: 0, marginBottom: '2rem' }}>
<nav style={{ position: 'relative', zIndex: 1, display: 'inline-flex', gap: '1rem', backgroundColor: 'rgba(255, 255, 255, 0.1)', borderRadius: '1rem', padding: '0.5rem' }}>
{navItems.map(({ key, icon, label }) => {
const active = isActive(key);
return (
<div
key={key}
onClick={() => onNavigate(key)}
style={{ position: 'relative', padding: '0.75rem 1.5rem', borderRadius: '0.5rem', cursor: 'pointer', display: 'flex', alignItems: 'center', color: active ? '#FCD34D' : '#d1d5db', fontWeight: active ? 'bold' : 'normal', zIndex: 1 }}
>
{icon}
<span style={{ marginLeft: '0.5rem' }}>{label}</span>
{active && (
<motion.div
layoutId="active-pill"
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(251, 191, 36, 0.2)',
borderRadius: '0.5rem',
zIndex: -1
}}
/>
)}
</div>
);
})}
</nav>
</div>
<main style={{ position: 'relative', zIndex: 1, flex: 1, overflowY: 'auto' }} className="hide-scrollbar">
{children}
</main>
{/* ⭐ NEW: Realtime Toast Notification Popup */}
<AnimatePresence>
{toastNotif && (
<motion.div
key={toastNotif.id}
initial={{ opacity: 0, y: -20, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.9 }}
style={{
position: 'absolute',
top: '60px', // Below the header
right: '2rem', // Aligned near the bell icon
backgroundColor: '#1e293b',
border: '1px solid rgba(255,255,255,0.1)',
borderLeft: '4px solid #FBBF24',
borderRadius: '12px',
padding: '1rem 1.5rem',
display: 'flex',
alignItems: 'flex-start',
gap: '1rem',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3)',
zIndex: 9999,
width: '320px',
maxWidth: '90vw'
}}
>
<span style={{ fontSize: '1.8rem' }}>{toastNotif.icon}</span>
<div>
<h4 style={{ margin: 0, color: 'white', fontWeight: 'bold', fontSize: '1rem' }}>{toastNotif.title}</h4>
<p style={{ margin: '0.25rem 0 0 0', color: '#cbd5e1', fontSize: '0.9rem' }}>{toastNotif.message}</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}