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