Spaces:
Sleeping
Sleeping
Muhammed Sameer commited on
Commit ·
59f9574
1
Parent(s): cf06972
new feature implemented
Browse files- src/App.jsx +2 -1
- src/components/Admin/AdminInterviewManagement.jsx +36 -6
- src/components/Admin/AdminSortingPage.jsx +43 -4
- src/components/Admin/AdminSummary.jsx +163 -5
- src/components/ApplicantLayout.jsx +181 -71
- src/components/ExperienceChart.jsx +92 -47
- src/components/JobListings.jsx +26 -2
- src/pages/Admindashboard.jsx +3 -2
- src/pages/AppliLogin.jsx +2 -2
- src/pages/ApplicantJobPage.jsx +35 -4
- src/pages/ApplicantMessages.jsx +270 -49
- src/pages/ResetPassword.jsx +51 -0
src/App.jsx
CHANGED
|
@@ -13,6 +13,7 @@ import ApplicantProfile from './pages/ApplicantProfile'; // Make sure this fi
|
|
| 13 |
import ApplicantATS from './pages/ApplicantATS'; // Make sure this file exists
|
| 14 |
import ApplicantInterviews from './pages/ApplicantInterviews'; // Make sure this file exists
|
| 15 |
import ApplicantMessages from './pages/ApplicantMessages'; // Make sure this file exists
|
|
|
|
| 16 |
|
| 17 |
import { supabase } from './supabaseClient'; // Import at top
|
| 18 |
|
|
@@ -67,7 +68,7 @@ export default function App() {
|
|
| 67 |
case 'login': return <LoginPage onNavigate={handleNavigate} />;
|
| 68 |
case 'admin': return <AdminLogin onNavigate={handleNavigate} />;
|
| 69 |
case 'applicant': return <AppliLogin onNavigate={handleNavigate} />;
|
| 70 |
-
|
| 71 |
// --- APPLICANT ROUTES ---
|
| 72 |
case 'applicant-jobs':
|
| 73 |
return <ApplicantJobPage onNavigate={handleNavigate} />;
|
|
|
|
| 13 |
import ApplicantATS from './pages/ApplicantATS'; // Make sure this file exists
|
| 14 |
import ApplicantInterviews from './pages/ApplicantInterviews'; // Make sure this file exists
|
| 15 |
import ApplicantMessages from './pages/ApplicantMessages'; // Make sure this file exists
|
| 16 |
+
import ResetPassword from './pages/ResetPassword';
|
| 17 |
|
| 18 |
import { supabase } from './supabaseClient'; // Import at top
|
| 19 |
|
|
|
|
| 68 |
case 'login': return <LoginPage onNavigate={handleNavigate} />;
|
| 69 |
case 'admin': return <AdminLogin onNavigate={handleNavigate} />;
|
| 70 |
case 'applicant': return <AppliLogin onNavigate={handleNavigate} />;
|
| 71 |
+
case 'reset-password': return <ResetPassword onNavigate={handleNavigate} />;
|
| 72 |
// --- APPLICANT ROUTES ---
|
| 73 |
case 'applicant-jobs':
|
| 74 |
return <ApplicantJobPage onNavigate={handleNavigate} />;
|
src/components/Admin/AdminInterviewManagement.jsx
CHANGED
|
@@ -186,9 +186,7 @@ export default function AdminInterviewManagement() {
|
|
| 186 |
const [selectedApplicant, setSelectedApplicant] = useState(null);
|
| 187 |
const [drawerCandidate, setDrawerCandidate] = useState(null);
|
| 188 |
|
| 189 |
-
//
|
| 190 |
-
useEffect(() => { fetchData(); }, []);
|
| 191 |
-
|
| 192 |
const fetchData = async () => {
|
| 193 |
try {
|
| 194 |
setLoading(true);
|
|
@@ -198,7 +196,7 @@ export default function AdminInterviewManagement() {
|
|
| 198 |
.from('applications')
|
| 199 |
.select(`
|
| 200 |
id, user_id, job_id, created_at, status, experience, skills, match_score, resume_url,
|
| 201 |
-
profiles ( id, full_name, email, avatar_url, phone, location, summary, headline, current_position, education, work_experience, projects, skills, technical_skills, resume_url ),
|
| 202 |
jobs ( id, title ),
|
| 203 |
interviews ( id, date, time, status, created_at )
|
| 204 |
`)
|
|
@@ -272,7 +270,31 @@ export default function AdminInterviewManagement() {
|
|
| 272 |
}
|
| 273 |
};
|
| 274 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
// 2. Updated Schedule Handler
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
const handleScheduleConfirm = async (scheduleData) => {
|
| 277 |
if (!selectedApplicant) return;
|
| 278 |
|
|
@@ -311,6 +333,15 @@ export default function AdminInterviewManagement() {
|
|
| 311 |
|
| 312 |
if (dbError) throw dbError;
|
| 313 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
if (selectedApplicant.email) {
|
| 315 |
await supabase.functions.invoke('send-interview-email', {
|
| 316 |
body: {
|
|
@@ -322,7 +353,7 @@ export default function AdminInterviewManagement() {
|
|
| 322 |
});
|
| 323 |
}
|
| 324 |
|
| 325 |
-
alert("Interview Scheduled Successfully!");
|
| 326 |
setIsScheduleModalOpen(false);
|
| 327 |
fetchData();
|
| 328 |
|
|
@@ -411,7 +442,6 @@ export default function AdminInterviewManagement() {
|
|
| 411 |
)}
|
| 412 |
</AnimatePresence>
|
| 413 |
|
| 414 |
-
{/* ✅ NEW FULL CHAT MODAL */}
|
| 415 |
<AnimatePresence>
|
| 416 |
{isMessageModalOpen && (
|
| 417 |
<AdminChatModal
|
|
|
|
| 186 |
const [selectedApplicant, setSelectedApplicant] = useState(null);
|
| 187 |
const [drawerCandidate, setDrawerCandidate] = useState(null);
|
| 188 |
|
| 189 |
+
// ✅ FIXED: Marked this function as 'async' to resolve the Syntax Error
|
|
|
|
|
|
|
| 190 |
const fetchData = async () => {
|
| 191 |
try {
|
| 192 |
setLoading(true);
|
|
|
|
| 196 |
.from('applications')
|
| 197 |
.select(`
|
| 198 |
id, user_id, job_id, created_at, status, experience, skills, match_score, resume_url,
|
| 199 |
+
profiles ( id, full_name, email, avatar_url, phone, location, summary, headline, current_position, education, work_experience, experience_years, projects, skills, technical_skills, resume_url ),
|
| 200 |
jobs ( id, title ),
|
| 201 |
interviews ( id, date, time, status, created_at )
|
| 202 |
`)
|
|
|
|
| 270 |
}
|
| 271 |
};
|
| 272 |
|
| 273 |
+
// 1. Fetch Data on Component Mount
|
| 274 |
+
useEffect(() => {
|
| 275 |
+
fetchData();
|
| 276 |
+
}, []);
|
| 277 |
+
|
| 278 |
// 2. Updated Schedule Handler
|
| 279 |
+
// ✅ Helper: Send message to candidate
|
| 280 |
+
const sendMessageToCandidate = async (candidateUserId, message) => {
|
| 281 |
+
try {
|
| 282 |
+
const { data: { user: adminUser } } = await supabase.auth.getUser();
|
| 283 |
+
if (!adminUser) return;
|
| 284 |
+
|
| 285 |
+
const { error } = await supabase.from('messages').insert([{
|
| 286 |
+
sender_id: adminUser.id,
|
| 287 |
+
receiver_id: candidateUserId,
|
| 288 |
+
content: message,
|
| 289 |
+
is_read: false
|
| 290 |
+
}]);
|
| 291 |
+
|
| 292 |
+
if (error) console.error('Error sending message:', error);
|
| 293 |
+
} catch (err) {
|
| 294 |
+
console.error('Failed to send message:', err);
|
| 295 |
+
}
|
| 296 |
+
};
|
| 297 |
+
|
| 298 |
const handleScheduleConfirm = async (scheduleData) => {
|
| 299 |
if (!selectedApplicant) return;
|
| 300 |
|
|
|
|
| 333 |
|
| 334 |
if (dbError) throw dbError;
|
| 335 |
|
| 336 |
+
// ✅ Send interview scheduled message to candidate
|
| 337 |
+
const interviewDatesTime = `${date} at ${time}`;
|
| 338 |
+
const modeInfo = mode === 'Online' ? `via ${details}` : `at ${details}`;
|
| 339 |
+
const jobContext = selectedApplicant.role ? ` for ${selectedApplicant.role}` : '';
|
| 340 |
+
await sendMessageToCandidate(
|
| 341 |
+
selectedApplicant.userId,
|
| 342 |
+
`📅 Great news! Your interview${jobContext} has been scheduled for ${interviewDatesTime} (${interviewType}) ${modeInfo}. Interviewer: ${interviewerName} (${interviewerRole}). Please confirm your availability.`
|
| 343 |
+
);
|
| 344 |
+
|
| 345 |
if (selectedApplicant.email) {
|
| 346 |
await supabase.functions.invoke('send-interview-email', {
|
| 347 |
body: {
|
|
|
|
| 353 |
});
|
| 354 |
}
|
| 355 |
|
| 356 |
+
alert("Interview Scheduled Successfully and candidate notified!");
|
| 357 |
setIsScheduleModalOpen(false);
|
| 358 |
fetchData();
|
| 359 |
|
|
|
|
| 442 |
)}
|
| 443 |
</AnimatePresence>
|
| 444 |
|
|
|
|
| 445 |
<AnimatePresence>
|
| 446 |
{isMessageModalOpen && (
|
| 447 |
<AdminChatModal
|
src/components/Admin/AdminSortingPage.jsx
CHANGED
|
@@ -262,6 +262,25 @@ export default function AdminSortingPage() {
|
|
| 262 |
fetchApplicants();
|
| 263 |
}, []);
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
// --- BULK ACTIONS ---
|
| 266 |
const handleBulkReject = async () => {
|
| 267 |
if (!confirm(`Are you sure you want to REJECT ${selectedIds.length} candidates?`)) return;
|
|
@@ -269,17 +288,27 @@ export default function AdminSortingPage() {
|
|
| 269 |
try {
|
| 270 |
const { error } = await supabase
|
| 271 |
.from('applications')
|
| 272 |
-
.update({ status: 'Rejected' })
|
| 273 |
.in('id', selectedIds);
|
| 274 |
if (error) throw error;
|
| 275 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
// Update UI instantly
|
| 277 |
setApplicants(prev => prev.map(app =>
|
| 278 |
selectedIds.includes(app.id) ? { ...app, status: 'Rejected' } : app
|
| 279 |
));
|
| 280 |
|
| 281 |
-
setSelectedIds([]);
|
| 282 |
-
alert('Candidates Rejected.');
|
| 283 |
} catch (error) {
|
| 284 |
console.error('Error rejecting:', error.message);
|
| 285 |
alert('Failed to reject.');
|
|
@@ -298,11 +327,21 @@ export default function AdminSortingPage() {
|
|
| 298 |
|
| 299 |
if (error) throw error;
|
| 300 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
setApplicants(prev => prev.map(app =>
|
| 302 |
selectedIds.includes(app.id) ? { ...app, status: 'Accepted' } : app
|
| 303 |
));
|
| 304 |
setSelectedIds([]);
|
| 305 |
-
alert('Approved Successfully!');
|
| 306 |
} catch (error) {
|
| 307 |
console.error('Error approving:', error.message);
|
| 308 |
alert('Failed to update.');
|
|
|
|
| 262 |
fetchApplicants();
|
| 263 |
}, []);
|
| 264 |
|
| 265 |
+
// --- HELPER: Send message to candidate ---
|
| 266 |
+
const sendMessageToCandidate = async (candidateUserId, message) => {
|
| 267 |
+
try {
|
| 268 |
+
const { data: { user: adminUser } } = await supabase.auth.getUser();
|
| 269 |
+
if (!adminUser) return;
|
| 270 |
+
|
| 271 |
+
const { error } = await supabase.from('messages').insert([{
|
| 272 |
+
sender_id: adminUser.id,
|
| 273 |
+
receiver_id: candidateUserId,
|
| 274 |
+
content: message,
|
| 275 |
+
is_read: false
|
| 276 |
+
}]);
|
| 277 |
+
|
| 278 |
+
if (error) console.error('Error sending message:', error);
|
| 279 |
+
} catch (err) {
|
| 280 |
+
console.error('Failed to send message:', err);
|
| 281 |
+
}
|
| 282 |
+
};
|
| 283 |
+
|
| 284 |
// --- BULK ACTIONS ---
|
| 285 |
const handleBulkReject = async () => {
|
| 286 |
if (!confirm(`Are you sure you want to REJECT ${selectedIds.length} candidates?`)) return;
|
|
|
|
| 288 |
try {
|
| 289 |
const { error } = await supabase
|
| 290 |
.from('applications')
|
| 291 |
+
.update({ status: 'Rejected' })
|
| 292 |
.in('id', selectedIds);
|
| 293 |
if (error) throw error;
|
| 294 |
|
| 295 |
+
// ✅ Send rejection message to each candidate
|
| 296 |
+
const rejectedApplicants = applicants.filter(a => selectedIds.includes(a.id));
|
| 297 |
+
for (const applicant of rejectedApplicants) {
|
| 298 |
+
const jobContext = applicant.jobTitle ? ` for ${applicant.jobTitle}` : '';
|
| 299 |
+
await sendMessageToCandidate(
|
| 300 |
+
applicant.userId,
|
| 301 |
+
`📧 We regret to inform you that your application${jobContext} has been rejected. We appreciate your interest and wish you the best of luck in your career. Feel free to apply again in the future!`
|
| 302 |
+
);
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
// Update UI instantly
|
| 306 |
setApplicants(prev => prev.map(app =>
|
| 307 |
selectedIds.includes(app.id) ? { ...app, status: 'Rejected' } : app
|
| 308 |
));
|
| 309 |
|
| 310 |
+
setSelectedIds([]);
|
| 311 |
+
alert('Candidates Rejected and notified.');
|
| 312 |
} catch (error) {
|
| 313 |
console.error('Error rejecting:', error.message);
|
| 314 |
alert('Failed to reject.');
|
|
|
|
| 327 |
|
| 328 |
if (error) throw error;
|
| 329 |
|
| 330 |
+
// ✅ Send acceptance message to each candidate
|
| 331 |
+
const approvedApplicants = applicants.filter(a => selectedIds.includes(a.id));
|
| 332 |
+
for (const applicant of approvedApplicants) {
|
| 333 |
+
const jobContext = applicant.jobTitle ? ` for ${applicant.jobTitle}` : '';
|
| 334 |
+
await sendMessageToCandidate(
|
| 335 |
+
applicant.userId,
|
| 336 |
+
`🎉 Congratulations! Your application${jobContext} has been accepted. We are excited about the possibility of working with you. Our team will be in touch soon to schedule an interview.`
|
| 337 |
+
);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
setApplicants(prev => prev.map(app =>
|
| 341 |
selectedIds.includes(app.id) ? { ...app, status: 'Accepted' } : app
|
| 342 |
));
|
| 343 |
setSelectedIds([]);
|
| 344 |
+
alert('Approved Successfully and candidates notified!');
|
| 345 |
} catch (error) {
|
| 346 |
console.error('Error approving:', error.message);
|
| 347 |
alert('Failed to update.');
|
src/components/Admin/AdminSummary.jsx
CHANGED
|
@@ -23,7 +23,7 @@ const BellIcon = () => (
|
|
| 23 |
</svg>
|
| 24 |
);
|
| 25 |
|
| 26 |
-
export default function AdminSummary({ onNavigate }) {
|
| 27 |
const containerVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.1 } } };
|
| 28 |
const itemVariants = { hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0 } };
|
| 29 |
|
|
@@ -36,6 +36,7 @@ export default function AdminSummary({ onNavigate }) {
|
|
| 36 |
// ✅ Notification State
|
| 37 |
const [notifications, setNotifications] = useState([]);
|
| 38 |
const [showNotifications, setShowNotifications] = useState(false);
|
|
|
|
| 39 |
const notifRef = useRef(null);
|
| 40 |
|
| 41 |
// ✅ CLICK OUTSIDE LISTENER
|
|
@@ -54,6 +55,85 @@ export default function AdminSummary({ onNavigate }) {
|
|
| 54 |
fetchDashboardData();
|
| 55 |
}, []);
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
const fetchDashboardData = async () => {
|
| 58 |
try {
|
| 59 |
setLoading(true);
|
|
@@ -103,6 +183,53 @@ export default function AdminSummary({ onNavigate }) {
|
|
| 103 |
|
| 104 |
return (
|
| 105 |
<motion.div variants={containerVariants} initial="hidden" animate="visible">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
{/* ✅ HEADER SECTION */}
|
| 108 |
<motion.header
|
|
@@ -172,11 +299,42 @@ export default function AdminSummary({ onNavigate }) {
|
|
| 172 |
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
| 173 |
{notifications.length > 0 ? (
|
| 174 |
notifications.map(notif => (
|
| 175 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
<div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: notif.color, marginTop: '6px', flexShrink: 0 }}></div>
|
| 177 |
-
<div>
|
| 178 |
-
<p style={{ fontSize: '0.85rem', color: '#e2e8f0', margin: '0 0 0.25rem 0' }}>{notif.text}</p>
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
| 180 |
{new Date(notif.time).toLocaleDateString()} • {new Date(notif.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
| 181 |
</p>
|
| 182 |
</div>
|
|
|
|
| 23 |
</svg>
|
| 24 |
);
|
| 25 |
|
| 26 |
+
export default function AdminSummary({ onNavigate, setActiveTab, selectedChatUserId, setSelectedChatUserId }) {
|
| 27 |
const containerVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.1 } } };
|
| 28 |
const itemVariants = { hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0 } };
|
| 29 |
|
|
|
|
| 36 |
// ✅ Notification State
|
| 37 |
const [notifications, setNotifications] = useState([]);
|
| 38 |
const [showNotifications, setShowNotifications] = useState(false);
|
| 39 |
+
const [latestPopup, setLatestPopup] = useState(null); // ✅ Show only latest popup
|
| 40 |
const notifRef = useRef(null);
|
| 41 |
|
| 42 |
// ✅ CLICK OUTSIDE LISTENER
|
|
|
|
| 55 |
fetchDashboardData();
|
| 56 |
}, []);
|
| 57 |
|
| 58 |
+
// ✅ HELPER: Show popup notification (only latest)
|
| 59 |
+
const showPopup = (notif) => {
|
| 60 |
+
setLatestPopup(notif);
|
| 61 |
+
|
| 62 |
+
// Auto-dismiss after 4 seconds
|
| 63 |
+
setTimeout(() => {
|
| 64 |
+
setLatestPopup(null);
|
| 65 |
+
}, 4000);
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
// ✅ MESSAGE POLLING - Check for new messages every 5 seconds
|
| 69 |
+
useEffect(() => {
|
| 70 |
+
let pollInterval;
|
| 71 |
+
const startPolling = async () => {
|
| 72 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 73 |
+
console.log('📱 Polling started for user:', user?.id);
|
| 74 |
+
if (!user) return;
|
| 75 |
+
|
| 76 |
+
let lastMessageId = null;
|
| 77 |
+
|
| 78 |
+
pollInterval = setInterval(async () => {
|
| 79 |
+
try {
|
| 80 |
+
const { data: newMessages } = await supabase
|
| 81 |
+
.from('messages')
|
| 82 |
+
.select('id, sender_id, content, created_at')
|
| 83 |
+
.eq('receiver_id', user.id)
|
| 84 |
+
.order('id', { ascending: false })
|
| 85 |
+
.limit(1);
|
| 86 |
+
|
| 87 |
+
if (newMessages && newMessages.length > 0) {
|
| 88 |
+
const msg = newMessages[0];
|
| 89 |
+
console.log('📬 Checking messages:', msg.id, 'last:', lastMessageId);
|
| 90 |
+
|
| 91 |
+
// Only process if it's a new message
|
| 92 |
+
if (!lastMessageId || msg.id > lastMessageId) {
|
| 93 |
+
lastMessageId = msg.id;
|
| 94 |
+
|
| 95 |
+
// Get sender profile
|
| 96 |
+
const { data: senderProfile } = await supabase
|
| 97 |
+
.from('profiles')
|
| 98 |
+
.select('full_name')
|
| 99 |
+
.eq('id', msg.sender_id)
|
| 100 |
+
.single();
|
| 101 |
+
|
| 102 |
+
const senderName = senderProfile?.full_name || 'Candidate';
|
| 103 |
+
|
| 104 |
+
// Create and add notification
|
| 105 |
+
const newNotif = {
|
| 106 |
+
id: `msg-${msg.id}`,
|
| 107 |
+
type: 'New Message',
|
| 108 |
+
text: `📨 New message from ${senderName}`,
|
| 109 |
+
time: msg.created_at,
|
| 110 |
+
color: '#10b981',
|
| 111 |
+
preview: msg.content,
|
| 112 |
+
senderId: msg.sender_id // ✅ Store sender ID for navigation
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
console.log('🔔 Message notification:', newNotif);
|
| 116 |
+
|
| 117 |
+
setNotifications(prev => {
|
| 118 |
+
const exists = prev.some(n => n.id === newNotif.id);
|
| 119 |
+
if (exists) return prev;
|
| 120 |
+
return [newNotif, ...prev];
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
// ✅ SHOW POPUP
|
| 124 |
+
showPopup(newNotif);
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
} catch (err) {
|
| 128 |
+
console.error("Poll error:", err);
|
| 129 |
+
}
|
| 130 |
+
}, 3000); // Check every 3 seconds
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
startPolling();
|
| 134 |
+
return () => { if (pollInterval) clearInterval(pollInterval); };
|
| 135 |
+
}, [showPopup]);
|
| 136 |
+
|
| 137 |
const fetchDashboardData = async () => {
|
| 138 |
try {
|
| 139 |
setLoading(true);
|
|
|
|
| 183 |
|
| 184 |
return (
|
| 185 |
<motion.div variants={containerVariants} initial="hidden" animate="visible">
|
| 186 |
+
|
| 187 |
+
{/* ✅ SINGLE SMALL POPUP NOTIFICATION */}
|
| 188 |
+
<AnimatePresence>
|
| 189 |
+
{latestPopup && (
|
| 190 |
+
<motion.div
|
| 191 |
+
key="popup"
|
| 192 |
+
initial={{ opacity: 0, y: -15, x: 20 }}
|
| 193 |
+
animate={{ opacity: 1, y: 0, x: 0 }}
|
| 194 |
+
exit={{ opacity: 0, y: -15, x: 20 }}
|
| 195 |
+
style={{
|
| 196 |
+
position: 'fixed',
|
| 197 |
+
top: '20px',
|
| 198 |
+
right: '20px',
|
| 199 |
+
background: 'rgba(15, 23, 42, 0.98)',
|
| 200 |
+
border: `2px solid ${latestPopup.color}`,
|
| 201 |
+
borderRadius: '10px',
|
| 202 |
+
padding: '0.75rem 1rem',
|
| 203 |
+
width: '280px',
|
| 204 |
+
zIndex: 9999,
|
| 205 |
+
backdropFilter: 'blur(10px)',
|
| 206 |
+
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
|
| 207 |
+
fontFamily: 'inherit'
|
| 208 |
+
}}
|
| 209 |
+
>
|
| 210 |
+
<p style={{
|
| 211 |
+
color: '#e2e8f0',
|
| 212 |
+
margin: '0 0 0.3rem 0',
|
| 213 |
+
fontWeight: '600',
|
| 214 |
+
fontSize: '0.88rem'
|
| 215 |
+
}}>
|
| 216 |
+
{latestPopup.text}
|
| 217 |
+
</p>
|
| 218 |
+
{latestPopup.preview && (
|
| 219 |
+
<p style={{
|
| 220 |
+
color: '#cbd5e1',
|
| 221 |
+
margin: '0',
|
| 222 |
+
fontSize: '0.78rem',
|
| 223 |
+
whiteSpace: 'nowrap',
|
| 224 |
+
overflow: 'hidden',
|
| 225 |
+
textOverflow: 'ellipsis'
|
| 226 |
+
}}>
|
| 227 |
+
{latestPopup.preview}
|
| 228 |
+
</p>
|
| 229 |
+
)}
|
| 230 |
+
</motion.div>
|
| 231 |
+
)}
|
| 232 |
+
</AnimatePresence>
|
| 233 |
|
| 234 |
{/* ✅ HEADER SECTION */}
|
| 235 |
<motion.header
|
|
|
|
| 299 |
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
| 300 |
{notifications.length > 0 ? (
|
| 301 |
notifications.map(notif => (
|
| 302 |
+
<div
|
| 303 |
+
key={notif.id}
|
| 304 |
+
onClick={() => {
|
| 305 |
+
console.log('Notification clicked:', notif.type);
|
| 306 |
+
|
| 307 |
+
// ✅ Navigate based on notification type
|
| 308 |
+
if (notif.type === 'New Message' && notif.senderId) {
|
| 309 |
+
// For messages, set the selected chat user and go to messages tab
|
| 310 |
+
setSelectedChatUserId(notif.senderId);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
setActiveTab('messages');
|
| 314 |
+
setShowNotifications(false);
|
| 315 |
+
// ✅ Remove notification after clicking
|
| 316 |
+
setNotifications(prev => prev.filter(n => n.id !== notif.id));
|
| 317 |
+
}}
|
| 318 |
+
style={{
|
| 319 |
+
padding: '1rem',
|
| 320 |
+
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
| 321 |
+
display: 'flex',
|
| 322 |
+
gap: '0.75rem',
|
| 323 |
+
alignItems: 'start',
|
| 324 |
+
cursor: 'pointer',
|
| 325 |
+
transition: 'background 0.2s',
|
| 326 |
+
':hover': { background: 'rgba(255,255,255,0.05)' }
|
| 327 |
+
}}
|
| 328 |
+
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.08)'}
|
| 329 |
+
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
| 330 |
+
>
|
| 331 |
<div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: notif.color, marginTop: '6px', flexShrink: 0 }}></div>
|
| 332 |
+
<div style={{ flex: 1 }}>
|
| 333 |
+
<p style={{ fontSize: '0.85rem', color: '#e2e8f0', margin: '0 0 0.25rem 0', fontWeight: '500' }}>{notif.text}</p>
|
| 334 |
+
{notif.preview && (
|
| 335 |
+
<p style={{ fontSize: '0.75rem', color: '#cbd5e1', margin: '0.25rem 0 0 0', fontStyle: 'italic' }}>{notif.preview}</p>
|
| 336 |
+
)}
|
| 337 |
+
<p style={{ fontSize: '0.75rem', color: '#94a3b8', margin: '0.25rem 0 0 0' }}>
|
| 338 |
{new Date(notif.time).toLocaleDateString()} • {new Date(notif.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
| 339 |
</p>
|
| 340 |
</div>
|
src/components/ApplicantLayout.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
import { supabase } from '../supabaseClient';
|
| 4 |
import {
|
|
@@ -6,7 +6,6 @@ import {
|
|
| 6 |
CalendarIcon, AtsCheckerIcon
|
| 7 |
} from './Icons';
|
| 8 |
|
| 9 |
-
// ✅ ADDED: Bell Icon SVG
|
| 10 |
const BellIcon = () => (
|
| 11 |
<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">
|
| 12 |
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
|
@@ -16,16 +15,16 @@ const BellIcon = () => (
|
|
| 16 |
|
| 17 |
export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
| 18 |
|
| 19 |
-
// ✅ FIX: Initialize state directly from LocalStorage.
|
| 20 |
-
// This removes the "flicker" because it grabs the name instantly before the page paints.
|
| 21 |
const [userName, setUserName] = useState(() => localStorage.getItem('applicant_name') || '');
|
| 22 |
|
| 23 |
-
// ✅ ADDED: Notification States
|
| 24 |
const [notifications, setNotifications] = useState([]);
|
| 25 |
const [showNotifications, setShowNotifications] = useState(false);
|
| 26 |
const notifRef = useRef(null);
|
| 27 |
|
| 28 |
-
//
|
|
|
|
|
|
|
|
|
|
| 29 |
useEffect(() => {
|
| 30 |
function handleClickOutside(event) {
|
| 31 |
if (notifRef.current && !notifRef.current.contains(event.target)) {
|
|
@@ -49,11 +48,7 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 49 |
|
| 50 |
if (profile && profile.full_name) {
|
| 51 |
const firstName = profile.full_name.split(' ')[0];
|
| 52 |
-
|
| 53 |
-
// Update state
|
| 54 |
setUserName(firstName);
|
| 55 |
-
|
| 56 |
-
// ✅ Save to LocalStorage for next time (Instant load on refresh)
|
| 57 |
localStorage.setItem('applicant_name', firstName);
|
| 58 |
}
|
| 59 |
}
|
|
@@ -64,7 +59,73 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 64 |
fetchUserName();
|
| 65 |
}, []);
|
| 66 |
|
| 67 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
useEffect(() => {
|
| 69 |
const fetchNotifications = async () => {
|
| 70 |
if (activePage === 'applicant-profile') {
|
|
@@ -96,15 +157,37 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 96 |
}
|
| 97 |
};
|
| 98 |
fetchNotifications();
|
| 99 |
-
}, [activePage]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
const handleLogout = async () => {
|
| 102 |
await supabase.auth.signOut();
|
| 103 |
-
localStorage.removeItem('applicant_name');
|
| 104 |
onNavigate('login');
|
| 105 |
};
|
| 106 |
|
| 107 |
-
// Helper to check if a tab is active
|
| 108 |
const isActive = (key) => activePage === key;
|
| 109 |
|
| 110 |
const navItems = [
|
|
@@ -117,25 +200,14 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 117 |
|
| 118 |
return (
|
| 119 |
<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' }}>
|
| 120 |
-
<style>{` main::-webkit-scrollbar { width: 8px; } main::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.1); border-radius: 10px; } main::-webkit-scrollbar-thumb { background: #FBBF24; border-radius: 10px; } main::-webkit-scrollbar-thumb:hover { background: #FCD34D; } `}</style>
|
| 121 |
|
| 122 |
-
{/* Background Blobs */}
|
| 123 |
-
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 0, overflow: 'hidden', pointerEvents: 'none' }}>
|
| 124 |
-
<div style={{ position: 'absolute', borderRadius: '50%', filter: 'blur(80px)', opacity: 0.3, width: '400px', height: '400px', backgroundColor: '#FBBF24', top: '-50px', left: '-100px' }}></div>
|
| 125 |
-
<div style={{ position: 'absolute', borderRadius: '50%', filter: 'blur(80px)', opacity: 0.3, width: '400px', height: '400px', backgroundColor: '#F59E0B', bottom: '-80px', right: '-120px' }}></div>
|
| 126 |
-
</div>
|
| 127 |
-
|
| 128 |
-
{/* Header */}
|
| 129 |
<header style={{ position: 'relative', zIndex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexShrink: 0 }}>
|
| 130 |
-
{/* Name appears instantly now if cached */}
|
| 131 |
<h1 style={{ fontSize: '1.875rem', fontWeight: 'bold' }}>
|
| 132 |
{userName ? `Hi, ${userName} 👋` : 'Welcome 👋'}
|
| 133 |
</h1>
|
| 134 |
|
| 135 |
-
{/* ✅ ADDED: Group wrapper to place Bell and Logout together */}
|
| 136 |
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
| 137 |
|
| 138 |
-
{/* ✅ ADDED: Conditionally render Bell ONLY on profile page */}
|
| 139 |
{activePage === 'applicant-profile' && (
|
| 140 |
<div style={{ position: 'relative' }} ref={notifRef}>
|
| 141 |
<motion.button
|
|
@@ -153,47 +225,87 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 153 |
}}
|
| 154 |
>
|
| 155 |
<BellIcon />
|
| 156 |
-
{notifications.length > 0 && (
|
| 157 |
<span style={{
|
| 158 |
-
position: 'absolute',
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
| 161 |
borderRadius: '50%',
|
| 162 |
-
border: '2px solid #020617'
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
)}
|
| 165 |
</motion.button>
|
| 166 |
|
| 167 |
-
{/* Dropdown */}
|
| 168 |
<AnimatePresence>
|
| 169 |
{showNotifications && (
|
| 170 |
<motion.div
|
| 171 |
-
initial={{ opacity: 0, y: 10
|
| 172 |
-
animate={{ opacity: 1, y: 0
|
| 173 |
-
exit={{ opacity: 0, y: 10
|
| 174 |
style={{
|
| 175 |
-
position: 'absolute',
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
}}
|
| 179 |
>
|
| 180 |
-
<div style={{ padding: '1rem', borderBottom: '1px solid rgba(255,255,255,0.1)'
|
| 181 |
-
|
| 182 |
</div>
|
| 183 |
-
<div style={{
|
| 184 |
-
{notifications.length
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
<div>
|
| 189 |
-
<p style={{ fontSize: '0.85rem', color: '#e2e8f0', margin: '0 0 0.25rem 0', fontWeight: 'bold' }}>{notif.title}</p>
|
| 190 |
-
<p style={{ fontSize: '0.8rem', color: '#cbd5e1', margin: '0 0 0.25rem 0' }}>{notif.text}</p>
|
| 191 |
-
<p style={{ fontSize: '0.7rem', color: '#94a3b8', margin: 0 }}>{new Date(notif.time).toLocaleDateString()}</p>
|
| 192 |
-
</div>
|
| 193 |
-
</div>
|
| 194 |
-
))
|
| 195 |
) : (
|
| 196 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
)}
|
| 198 |
</div>
|
| 199 |
</motion.div>
|
|
@@ -214,9 +326,8 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 214 |
</div>
|
| 215 |
</header>
|
| 216 |
|
| 217 |
-
{/* Navigation Bar */}
|
| 218 |
<div style={{ display: 'flex', justifyContent: 'center', width: '100%', flexShrink: 0, marginBottom: '2rem' }}>
|
| 219 |
-
<nav style={{ position: 'relative', zIndex: 1, display: 'inline-flex', gap: '1rem', backgroundColor: 'rgba(255, 255, 255, 0.1)', borderRadius: '1rem', padding: '0.5rem'
|
| 220 |
{navItems.map(({ key, icon, label }) => {
|
| 221 |
const active = isActive(key);
|
| 222 |
return (
|
|
@@ -226,9 +337,19 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 226 |
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 }}
|
| 227 |
>
|
| 228 |
{icon}
|
| 229 |
-
<span style={{ marginLeft: '0.5rem'
|
|
|
|
| 230 |
{active && (
|
| 231 |
-
<motion.div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
)}
|
| 233 |
</div>
|
| 234 |
);
|
|
@@ -236,20 +357,9 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 236 |
</nav>
|
| 237 |
</div>
|
| 238 |
|
| 239 |
-
{
|
| 240 |
-
|
| 241 |
-
<AnimatePresence mode="wait">
|
| 242 |
-
<motion.div
|
| 243 |
-
key={activePage}
|
| 244 |
-
initial={{ opacity: 0, y: 10 }}
|
| 245 |
-
animate={{ opacity: 1, y: 0 }}
|
| 246 |
-
exit={{ opacity: 0, y: -10 }}
|
| 247 |
-
transition={{ duration: 0.2 }}
|
| 248 |
-
>
|
| 249 |
-
{children}
|
| 250 |
-
</motion.div>
|
| 251 |
-
</AnimatePresence>
|
| 252 |
</main>
|
| 253 |
</div>
|
| 254 |
);
|
| 255 |
-
}
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
import { supabase } from '../supabaseClient';
|
| 4 |
import {
|
|
|
|
| 6 |
CalendarIcon, AtsCheckerIcon
|
| 7 |
} from './Icons';
|
| 8 |
|
|
|
|
| 9 |
const BellIcon = () => (
|
| 10 |
<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">
|
| 11 |
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
|
|
|
| 15 |
|
| 16 |
export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
| 17 |
|
|
|
|
|
|
|
| 18 |
const [userName, setUserName] = useState(() => localStorage.getItem('applicant_name') || '');
|
| 19 |
|
|
|
|
| 20 |
const [notifications, setNotifications] = useState([]);
|
| 21 |
const [showNotifications, setShowNotifications] = useState(false);
|
| 22 |
const notifRef = useRef(null);
|
| 23 |
|
| 24 |
+
// ⭐ NEW: unread message badge state
|
| 25 |
+
const [unreadMessages, setUnreadMessages] = useState(0);
|
| 26 |
+
const [unreadMessagesList, setUnreadMessagesList] = useState([]);
|
| 27 |
+
|
| 28 |
useEffect(() => {
|
| 29 |
function handleClickOutside(event) {
|
| 30 |
if (notifRef.current && !notifRef.current.contains(event.target)) {
|
|
|
|
| 48 |
|
| 49 |
if (profile && profile.full_name) {
|
| 50 |
const firstName = profile.full_name.split(' ')[0];
|
|
|
|
|
|
|
| 51 |
setUserName(firstName);
|
|
|
|
|
|
|
| 52 |
localStorage.setItem('applicant_name', firstName);
|
| 53 |
}
|
| 54 |
}
|
|
|
|
| 59 |
fetchUserName();
|
| 60 |
}, []);
|
| 61 |
|
| 62 |
+
// ⭐ NEW: fetch unread messages count and details
|
| 63 |
+
useEffect(() => {
|
| 64 |
+
|
| 65 |
+
const fetchUnreadMessages = async () => {
|
| 66 |
+
|
| 67 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 68 |
+
if (!user) return;
|
| 69 |
+
|
| 70 |
+
const { data: messages } = await supabase
|
| 71 |
+
.from("messages")
|
| 72 |
+
.select("id, sender_id, content, created_at, profiles(full_name)")
|
| 73 |
+
.eq("receiver_id", user.id)
|
| 74 |
+
.eq("is_read", false)
|
| 75 |
+
.order("created_at", { ascending: false });
|
| 76 |
+
|
| 77 |
+
if (messages) {
|
| 78 |
+
setUnreadMessages(messages.length);
|
| 79 |
+
// Format messages with sender names and timestamps
|
| 80 |
+
const formattedMessages = messages.map(msg => ({
|
| 81 |
+
id: msg.id,
|
| 82 |
+
senderName: msg.profiles?.full_name || 'Someone',
|
| 83 |
+
content: msg.content,
|
| 84 |
+
timestamp: new Date(msg.created_at).toLocaleDateString() + ' • ' + new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
| 85 |
+
}));
|
| 86 |
+
setUnreadMessagesList(formattedMessages);
|
| 87 |
+
}
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
fetchUnreadMessages();
|
| 91 |
+
|
| 92 |
+
}, []);
|
| 93 |
+
|
| 94 |
+
// ⭐ NEW: realtime update for new messages
|
| 95 |
+
useEffect(() => {
|
| 96 |
+
|
| 97 |
+
const channel = supabase
|
| 98 |
+
.channel("messages-badge")
|
| 99 |
+
.on(
|
| 100 |
+
"postgres_changes",
|
| 101 |
+
{ event: "INSERT", schema: "public", table: "messages" },
|
| 102 |
+
async (payload) => {
|
| 103 |
+
// Fetch the sender's name and add to the list
|
| 104 |
+
const { data: senderProfile } = await supabase
|
| 105 |
+
.from("profiles")
|
| 106 |
+
.select("full_name")
|
| 107 |
+
.eq("id", payload.new.sender_id)
|
| 108 |
+
.single();
|
| 109 |
+
|
| 110 |
+
const newMsg = {
|
| 111 |
+
id: payload.new.id,
|
| 112 |
+
senderName: senderProfile?.full_name || 'Someone',
|
| 113 |
+
content: payload.new.content,
|
| 114 |
+
timestamp: new Date(payload.new.created_at).toLocaleDateString() + ' • ' + new Date(payload.new.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
setUnreadMessagesList(prev => [newMsg, ...prev]);
|
| 118 |
+
setUnreadMessages(prev => prev + 1);
|
| 119 |
+
}
|
| 120 |
+
)
|
| 121 |
+
.subscribe();
|
| 122 |
+
|
| 123 |
+
return () => {
|
| 124 |
+
supabase.removeChannel(channel);
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
}, []);
|
| 128 |
+
|
| 129 |
useEffect(() => {
|
| 130 |
const fetchNotifications = async () => {
|
| 131 |
if (activePage === 'applicant-profile') {
|
|
|
|
| 157 |
}
|
| 158 |
};
|
| 159 |
fetchNotifications();
|
| 160 |
+
}, [activePage]);
|
| 161 |
+
|
| 162 |
+
// ⭐ NEW: mark messages read when user opens messages page
|
| 163 |
+
useEffect(() => {
|
| 164 |
+
|
| 165 |
+
const markMessagesRead = async () => {
|
| 166 |
+
|
| 167 |
+
if (activePage !== "applicant-messages") return;
|
| 168 |
+
|
| 169 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 170 |
+
if (!user) return;
|
| 171 |
+
|
| 172 |
+
await supabase
|
| 173 |
+
.from("messages")
|
| 174 |
+
.update({ is_read: true })
|
| 175 |
+
.eq("receiver_id", user.id);
|
| 176 |
+
|
| 177 |
+
setUnreadMessages(0);
|
| 178 |
+
setUnreadMessagesList([]);
|
| 179 |
+
};
|
| 180 |
+
|
| 181 |
+
markMessagesRead();
|
| 182 |
+
|
| 183 |
+
}, [activePage]);
|
| 184 |
|
| 185 |
const handleLogout = async () => {
|
| 186 |
await supabase.auth.signOut();
|
| 187 |
+
localStorage.removeItem('applicant_name');
|
| 188 |
onNavigate('login');
|
| 189 |
};
|
| 190 |
|
|
|
|
| 191 |
const isActive = (key) => activePage === key;
|
| 192 |
|
| 193 |
const navItems = [
|
|
|
|
| 200 |
|
| 201 |
return (
|
| 202 |
<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' }}>
|
|
|
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
<header style={{ position: 'relative', zIndex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexShrink: 0 }}>
|
|
|
|
| 205 |
<h1 style={{ fontSize: '1.875rem', fontWeight: 'bold' }}>
|
| 206 |
{userName ? `Hi, ${userName} 👋` : 'Welcome 👋'}
|
| 207 |
</h1>
|
| 208 |
|
|
|
|
| 209 |
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
| 210 |
|
|
|
|
| 211 |
{activePage === 'applicant-profile' && (
|
| 212 |
<div style={{ position: 'relative' }} ref={notifRef}>
|
| 213 |
<motion.button
|
|
|
|
| 225 |
}}
|
| 226 |
>
|
| 227 |
<BellIcon />
|
| 228 |
+
{(notifications.length + unreadMessages) > 0 && (
|
| 229 |
<span style={{
|
| 230 |
+
position: 'absolute',
|
| 231 |
+
top: '-5px',
|
| 232 |
+
right: '-5px',
|
| 233 |
+
width: '24px',
|
| 234 |
+
height: '24px',
|
| 235 |
+
backgroundColor: '#FBBF24',
|
| 236 |
borderRadius: '50%',
|
| 237 |
+
border: '2px solid #020617',
|
| 238 |
+
display: 'flex',
|
| 239 |
+
alignItems: 'center',
|
| 240 |
+
justifyContent: 'center',
|
| 241 |
+
color: '#1a202c',
|
| 242 |
+
fontSize: '0.75rem',
|
| 243 |
+
fontWeight: 'bold'
|
| 244 |
+
}}>
|
| 245 |
+
{notifications.length + unreadMessages}
|
| 246 |
+
</span>
|
| 247 |
)}
|
| 248 |
</motion.button>
|
| 249 |
|
|
|
|
| 250 |
<AnimatePresence>
|
| 251 |
{showNotifications && (
|
| 252 |
<motion.div
|
| 253 |
+
initial={{ opacity: 0, y: 10 }}
|
| 254 |
+
animate={{ opacity: 1, y: 0 }}
|
| 255 |
+
exit={{ opacity: 0, y: 10 }}
|
| 256 |
style={{
|
| 257 |
+
position: 'absolute',
|
| 258 |
+
top: '55px',
|
| 259 |
+
right: '0',
|
| 260 |
+
width: '320px',
|
| 261 |
+
backgroundColor: '#1e293b',
|
| 262 |
+
border: '1px solid rgba(255,255,255,0.1)',
|
| 263 |
+
borderRadius: '12px',
|
| 264 |
+
zIndex: 50,
|
| 265 |
+
maxHeight: '300px',
|
| 266 |
+
overflowY: 'auto'
|
| 267 |
}}
|
| 268 |
>
|
| 269 |
+
<div style={{ padding: '1rem', fontWeight: 'bold', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
|
| 270 |
+
Recent Notification ({notifications.length + unreadMessages})
|
| 271 |
</div>
|
| 272 |
+
<div style={{ padding: '0.75rem' }}>
|
| 273 |
+
{notifications.length + unreadMessages === 0 ? (
|
| 274 |
+
<div style={{ padding: '1rem', textAlign: 'center', color: '#94a3b8', fontSize: '0.85rem' }}>
|
| 275 |
+
No new notifications
|
| 276 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
) : (
|
| 278 |
+
<>
|
| 279 |
+
{unreadMessagesList.map(msg => (
|
| 280 |
+
<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' }}>
|
| 281 |
+
<span style={{ color: '#3b82f6', fontSize: '1rem' }}>📨</span>
|
| 282 |
+
<div style={{ flex: 1 }}>
|
| 283 |
+
<p style={{ margin: 0, fontSize: '0.85rem', color: '#e2e8f0', fontWeight: 500 }}>
|
| 284 |
+
{msg.senderName}
|
| 285 |
+
</p>
|
| 286 |
+
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.75rem', color: '#cbd5e1' }}>
|
| 287 |
+
{msg.timestamp}
|
| 288 |
+
</p>
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
))}
|
| 292 |
+
{notifications.map(notif => (
|
| 293 |
+
<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' }}>
|
| 294 |
+
<span style={{ color: notif.color, fontSize: '1rem' }}>•</span>
|
| 295 |
+
<div style={{ flex: 1 }}>
|
| 296 |
+
<p style={{ margin: 0, fontSize: '0.85rem', color: '#e2e8f0', fontWeight: 500 }}>
|
| 297 |
+
{notif.title}
|
| 298 |
+
</p>
|
| 299 |
+
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.75rem', color: '#cbd5e1' }}>
|
| 300 |
+
{notif.text}
|
| 301 |
+
</p>
|
| 302 |
+
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.7rem', color: '#64748b' }}>
|
| 303 |
+
{notif.time}
|
| 304 |
+
</p>
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
))}
|
| 308 |
+
</>
|
| 309 |
)}
|
| 310 |
</div>
|
| 311 |
</motion.div>
|
|
|
|
| 326 |
</div>
|
| 327 |
</header>
|
| 328 |
|
|
|
|
| 329 |
<div style={{ display: 'flex', justifyContent: 'center', width: '100%', flexShrink: 0, marginBottom: '2rem' }}>
|
| 330 |
+
<nav style={{ position: 'relative', zIndex: 1, display: 'inline-flex', gap: '1rem', backgroundColor: 'rgba(255, 255, 255, 0.1)', borderRadius: '1rem', padding: '0.5rem' }}>
|
| 331 |
{navItems.map(({ key, icon, label }) => {
|
| 332 |
const active = isActive(key);
|
| 333 |
return (
|
|
|
|
| 337 |
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 }}
|
| 338 |
>
|
| 339 |
{icon}
|
| 340 |
+
<span style={{ marginLeft: '0.5rem' }}>{label}</span>
|
| 341 |
+
|
| 342 |
{active && (
|
| 343 |
+
<motion.div
|
| 344 |
+
layoutId="active-pill"
|
| 345 |
+
style={{
|
| 346 |
+
position: 'absolute',
|
| 347 |
+
inset: 0,
|
| 348 |
+
backgroundColor: 'rgba(251, 191, 36, 0.2)',
|
| 349 |
+
borderRadius: '0.5rem',
|
| 350 |
+
zIndex: -1
|
| 351 |
+
}}
|
| 352 |
+
/>
|
| 353 |
)}
|
| 354 |
</div>
|
| 355 |
);
|
|
|
|
| 357 |
</nav>
|
| 358 |
</div>
|
| 359 |
|
| 360 |
+
<main style={{ position: 'relative', zIndex: 1, flex: 1, overflowY: 'auto' }}>
|
| 361 |
+
{children}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
</main>
|
| 363 |
</div>
|
| 364 |
);
|
| 365 |
+
}
|
src/components/ExperienceChart.jsx
CHANGED
|
@@ -1,61 +1,77 @@
|
|
|
|
|
| 1 |
import React, { useMemo } from 'react';
|
| 2 |
import { motion } from 'framer-motion';
|
| 3 |
|
| 4 |
const ExperienceChart = ({ applicants = [] }) => {
|
| 5 |
|
| 6 |
const avgExperience = useMemo(() => {
|
| 7 |
-
if (!applicants || applicants.length === 0) return "0.0";
|
| 8 |
-
|
| 9 |
-
const totalExp = applicants.reduce((acc, curr) => {
|
| 10 |
-
// ✅ FIX: Check all possible locations for the data
|
| 11 |
-
// 1. 'curr.experience': If formatted by a parent component
|
| 12 |
-
// 2. 'curr.profiles.experience_years': The actual DB column name
|
| 13 |
-
// 3. 'curr.profiles.experience': A fallback
|
| 14 |
-
const rawValue =
|
| 15 |
-
curr.experience ??
|
| 16 |
-
curr.profiles?.experience_years ??
|
| 17 |
-
curr.profiles?.experience;
|
| 18 |
-
|
| 19 |
-
// Handle null/undefined explicitly
|
| 20 |
-
if (rawValue === null || rawValue === undefined) return acc;
|
| 21 |
-
|
| 22 |
-
const strVal = String(rawValue).toLowerCase();
|
| 23 |
-
|
| 24 |
-
// Filter out text that clearly indicates no experience
|
| 25 |
-
if (strVal.includes('fresher') || strVal.includes('none') || strVal === '') {
|
| 26 |
-
return acc;
|
| 27 |
-
}
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
|
| 34 |
-
const extractedNum = match ? parseFloat(match[0]) : 0;
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
|
| 39 |
-
/
|
| 40 |
-
const avg = totalExp / applicants.length;
|
| 41 |
|
| 42 |
-
|
| 43 |
-
return isNaN(avg) ? "0.0" : avg.toFixed(1);
|
| 44 |
|
| 45 |
}, [applicants]);
|
| 46 |
|
| 47 |
return (
|
| 48 |
-
<div style={{
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
<path
|
| 53 |
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
| 54 |
fill="none"
|
| 55 |
-
stroke="rgba(255,
|
| 56 |
strokeWidth="3"
|
| 57 |
/>
|
| 58 |
-
|
| 59 |
<motion.path
|
| 60 |
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
| 61 |
fill="none"
|
|
@@ -63,31 +79,60 @@ const ExperienceChart = ({ applicants = [] }) => {
|
|
| 63 |
strokeWidth="3"
|
| 64 |
strokeLinecap="round"
|
| 65 |
initial={{ pathLength: 0 }}
|
| 66 |
-
|
| 67 |
-
|
|
|
|
| 68 |
transition={{ duration: 1.5, ease: "easeOut" }}
|
| 69 |
/>
|
|
|
|
| 70 |
</svg>
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
<motion.span
|
| 75 |
initial={{ opacity: 0, scale: 0.5 }}
|
| 76 |
animate={{ opacity: 1, scale: 1 }}
|
| 77 |
transition={{ delay: 0.2 }}
|
| 78 |
-
style={{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
>
|
| 80 |
{avgExperience}
|
| 81 |
</motion.span>
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
</div>
|
|
|
|
| 84 |
</div>
|
| 85 |
|
| 86 |
-
<p style={{
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
</p>
|
|
|
|
| 89 |
</div>
|
| 90 |
);
|
| 91 |
};
|
| 92 |
|
| 93 |
-
export default ExperienceChart;
|
|
|
|
|
|
| 1 |
+
|
| 2 |
import React, { useMemo } from 'react';
|
| 3 |
import { motion } from 'framer-motion';
|
| 4 |
|
| 5 |
const ExperienceChart = ({ applicants = [] }) => {
|
| 6 |
|
| 7 |
const avgExperience = useMemo(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
console.log("📊 Applications received:", applicants);
|
| 10 |
+
|
| 11 |
+
if (!applicants || applicants.length === 0) {
|
| 12 |
+
return "0.0";
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
let totalExp = 0;
|
| 16 |
+
|
| 17 |
+
applicants.forEach((app, index) => {
|
| 18 |
+
|
| 19 |
+
const rawExp = app?.experience;
|
| 20 |
+
|
| 21 |
+
console.log(`🔍 Applicant [${index}] experience:`, rawExp);
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
const exp = parseFloat(rawExp);
|
| 26 |
+
|
| 27 |
+
if (!isNaN(exp)) {
|
| 28 |
+
totalExp += exp;
|
| 29 |
+
}
|
| 30 |
|
| 31 |
+
});
|
|
|
|
| 32 |
|
| 33 |
+
console.log("📈 Total Experience:", totalExp);
|
| 34 |
+
console.log("👥 Total Candidates:", applicants.length);
|
| 35 |
|
| 36 |
+
const avg = applicants.length > 0 ? totalExp / applicants.length : 0;
|
|
|
|
| 37 |
|
| 38 |
+
return avg.toFixed(1);
|
|
|
|
| 39 |
|
| 40 |
}, [applicants]);
|
| 41 |
|
| 42 |
return (
|
| 43 |
+
<div style={{
|
| 44 |
+
display: 'flex',
|
| 45 |
+
flexDirection: 'column',
|
| 46 |
+
alignItems: 'center',
|
| 47 |
+
justifyContent: 'center',
|
| 48 |
+
height: '100%'
|
| 49 |
+
}}>
|
| 50 |
+
<div style={{
|
| 51 |
+
position: 'relative',
|
| 52 |
+
width: '160px',
|
| 53 |
+
height: '160px',
|
| 54 |
+
display: 'flex',
|
| 55 |
+
alignItems: 'center',
|
| 56 |
+
justifyContent: 'center'
|
| 57 |
+
}}>
|
| 58 |
+
|
| 59 |
+
<svg
|
| 60 |
+
viewBox="0 0 36 36"
|
| 61 |
+
style={{
|
| 62 |
+
width: '100%',
|
| 63 |
+
height: '100%',
|
| 64 |
+
transform: 'rotate(-90deg)'
|
| 65 |
+
}}
|
| 66 |
+
>
|
| 67 |
+
|
| 68 |
<path
|
| 69 |
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
| 70 |
fill="none"
|
| 71 |
+
stroke="rgba(255,255,255,0.1)"
|
| 72 |
strokeWidth="3"
|
| 73 |
/>
|
| 74 |
+
|
| 75 |
<motion.path
|
| 76 |
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
| 77 |
fill="none"
|
|
|
|
| 79 |
strokeWidth="3"
|
| 80 |
strokeLinecap="round"
|
| 81 |
initial={{ pathLength: 0 }}
|
| 82 |
+
animate={{
|
| 83 |
+
pathLength: Math.min(parseFloat(avgExperience) / 10, 1)
|
| 84 |
+
}}
|
| 85 |
transition={{ duration: 1.5, ease: "easeOut" }}
|
| 86 |
/>
|
| 87 |
+
|
| 88 |
</svg>
|
| 89 |
|
| 90 |
+
<div style={{
|
| 91 |
+
position: 'absolute',
|
| 92 |
+
textAlign: 'center',
|
| 93 |
+
display: 'flex',
|
| 94 |
+
flexDirection: 'column',
|
| 95 |
+
alignItems: 'center'
|
| 96 |
+
}}>
|
| 97 |
+
|
| 98 |
<motion.span
|
| 99 |
initial={{ opacity: 0, scale: 0.5 }}
|
| 100 |
animate={{ opacity: 1, scale: 1 }}
|
| 101 |
transition={{ delay: 0.2 }}
|
| 102 |
+
style={{
|
| 103 |
+
fontSize: '2.5rem',
|
| 104 |
+
fontWeight: 'bold',
|
| 105 |
+
color: 'white',
|
| 106 |
+
lineHeight: '1'
|
| 107 |
+
}}
|
| 108 |
>
|
| 109 |
{avgExperience}
|
| 110 |
</motion.span>
|
| 111 |
+
|
| 112 |
+
<span style={{
|
| 113 |
+
fontSize: '0.875rem',
|
| 114 |
+
color: '#9ca3af',
|
| 115 |
+
marginTop: '0.25rem'
|
| 116 |
+
}}>
|
| 117 |
+
Avg. Years
|
| 118 |
+
</span>
|
| 119 |
+
|
| 120 |
</div>
|
| 121 |
+
|
| 122 |
</div>
|
| 123 |
|
| 124 |
+
<p style={{
|
| 125 |
+
textAlign: 'center',
|
| 126 |
+
fontSize: '0.85rem',
|
| 127 |
+
color: '#6b7280',
|
| 128 |
+
marginTop: '1rem'
|
| 129 |
+
}}>
|
| 130 |
+
Based on application data
|
| 131 |
</p>
|
| 132 |
+
|
| 133 |
</div>
|
| 134 |
);
|
| 135 |
};
|
| 136 |
|
| 137 |
+
export default ExperienceChart;
|
| 138 |
+
|
src/components/JobListings.jsx
CHANGED
|
@@ -45,6 +45,26 @@ export default function JobListings({ searchQuery, setSearchQuery, isSearching,
|
|
| 45 |
}
|
| 46 |
};
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
// 3. Submit Application (With Verification Gatekeeper)
|
| 49 |
const handleFinalSubmit = async (formData) => {
|
| 50 |
if (!jobToApply) return;
|
|
@@ -62,7 +82,7 @@ export default function JobListings({ searchQuery, setSearchQuery, isSearching,
|
|
| 62 |
// --- 🔒 GATEKEEPER CHECK: Verify Phone Status ---
|
| 63 |
const { data: profile, error: profileError } = await supabase
|
| 64 |
.from('profiles')
|
| 65 |
-
.select('is_phone_verified')
|
| 66 |
.eq('id', user.id)
|
| 67 |
.single();
|
| 68 |
|
|
@@ -84,11 +104,15 @@ export default function JobListings({ searchQuery, setSearchQuery, isSearching,
|
|
| 84 |
user_id: user.id,
|
| 85 |
status: 'Pending',
|
| 86 |
resume_url: formData.resume_url,
|
| 87 |
-
cover_letter: formData.cover_letter
|
|
|
|
| 88 |
}]);
|
| 89 |
|
| 90 |
if (error) throw error;
|
| 91 |
|
|
|
|
|
|
|
|
|
|
| 92 |
setAppliedJobIds(prev => new Set(prev).add(jobToApply.id));
|
| 93 |
alert("Application submitted successfully!");
|
| 94 |
|
|
|
|
| 45 |
}
|
| 46 |
};
|
| 47 |
|
| 48 |
+
// ✅ Helper: Send welcome message to applicant
|
| 49 |
+
const sendApplicationConfirmationMessage = async (userId, jobTitle) => {
|
| 50 |
+
try {
|
| 51 |
+
const { data: { user: adminUser } } = await supabase.auth.getUser();
|
| 52 |
+
if (!adminUser) return;
|
| 53 |
+
|
| 54 |
+
const message = `Hello, Thank you for applying for the **${jobTitle}** position. We have received your application and our team is currently reviewing your profile. If your qualifications match our requirements, we will contact you shortly regarding the next steps in the selection process. We appreciate your interest in this opportunity.`;
|
| 55 |
+
|
| 56 |
+
const { error } = await supabase.from('messages').insert([{
|
| 57 |
+
sender_id: adminUser.id,
|
| 58 |
+
receiver_id: userId,
|
| 59 |
+
content: message
|
| 60 |
+
}]);
|
| 61 |
+
|
| 62 |
+
if (error) console.error('Error sending confirmation message:', error);
|
| 63 |
+
} catch (err) {
|
| 64 |
+
console.error('Failed to send confirmation message:', err);
|
| 65 |
+
}
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
// 3. Submit Application (With Verification Gatekeeper)
|
| 69 |
const handleFinalSubmit = async (formData) => {
|
| 70 |
if (!jobToApply) return;
|
|
|
|
| 82 |
// --- 🔒 GATEKEEPER CHECK: Verify Phone Status ---
|
| 83 |
const { data: profile, error: profileError } = await supabase
|
| 84 |
.from('profiles')
|
| 85 |
+
.select('is_phone_verified, experience_years')
|
| 86 |
.eq('id', user.id)
|
| 87 |
.single();
|
| 88 |
|
|
|
|
| 104 |
user_id: user.id,
|
| 105 |
status: 'Pending',
|
| 106 |
resume_url: formData.resume_url,
|
| 107 |
+
cover_letter: formData.cover_letter,
|
| 108 |
+
experience: profile.experience // Include experience from profile
|
| 109 |
}]);
|
| 110 |
|
| 111 |
if (error) throw error;
|
| 112 |
|
| 113 |
+
// ✅ Send confirmation message to applicant
|
| 114 |
+
await sendApplicationConfirmationMessage(user.id, jobToApply.title);
|
| 115 |
+
|
| 116 |
setAppliedJobIds(prev => new Set(prev).add(jobToApply.id));
|
| 117 |
alert("Application submitted successfully!");
|
| 118 |
|
src/pages/Admindashboard.jsx
CHANGED
|
@@ -14,15 +14,16 @@ import TalentClusters from '../components/Admin/TalentClusters';
|
|
| 14 |
export default function AdminDashboard({ onNavigate }) {
|
| 15 |
const [activeTab, setActiveTab] = useState('dashboard');
|
| 16 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
| 17 |
|
| 18 |
const renderContent = () => {
|
| 19 |
switch (activeTab) {
|
| 20 |
case 'dashboard':
|
| 21 |
-
return <AdminSummary onNavigate={onNavigate} setIsModalOpen={setIsModalOpen} />;
|
| 22 |
case 'jobs':
|
| 23 |
return <AdminSortingPage />;
|
| 24 |
case 'messages':
|
| 25 |
-
return <AdminInterviewManagement />;
|
| 26 |
case 'job-management':
|
| 27 |
return <JobPosting />;
|
| 28 |
case 'clusters':
|
|
|
|
| 14 |
export default function AdminDashboard({ onNavigate }) {
|
| 15 |
const [activeTab, setActiveTab] = useState('dashboard');
|
| 16 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 17 |
+
const [selectedChatUserId, setSelectedChatUserId] = useState(null); // ✅ Track selected chat user
|
| 18 |
|
| 19 |
const renderContent = () => {
|
| 20 |
switch (activeTab) {
|
| 21 |
case 'dashboard':
|
| 22 |
+
return <AdminSummary onNavigate={onNavigate} setIsModalOpen={setIsModalOpen} setActiveTab={setActiveTab} selectedChatUserId={selectedChatUserId} setSelectedChatUserId={setSelectedChatUserId} />;
|
| 23 |
case 'jobs':
|
| 24 |
return <AdminSortingPage />;
|
| 25 |
case 'messages':
|
| 26 |
+
return <AdminInterviewManagement selectedChatUserId={selectedChatUserId} setSelectedChatUserId={setSelectedChatUserId} />;
|
| 27 |
case 'job-management':
|
| 28 |
return <JobPosting />;
|
| 29 |
case 'clusters':
|
src/pages/AppliLogin.jsx
CHANGED
|
@@ -68,11 +68,11 @@ export default function AppliLogin({ onNavigate }) {
|
|
| 68 |
options: { data: { role: 'applicant' } }
|
| 69 |
});
|
| 70 |
if (error) throw error;
|
| 71 |
-
setNotification({ type: 'success', message: 'Registration successful! Please login.' });
|
| 72 |
setMode('login');
|
| 73 |
} else if (mode === 'forgot') {
|
| 74 |
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
| 75 |
-
redirectTo: window.location.origin,
|
| 76 |
});
|
| 77 |
if (error) throw error;
|
| 78 |
setNotification({ type: 'success', message: 'Password reset link sent! Check your email.' });
|
|
|
|
| 68 |
options: { data: { role: 'applicant' } }
|
| 69 |
});
|
| 70 |
if (error) throw error;
|
| 71 |
+
setNotification({ type: 'success', message: 'Registration successful! Please confirm your Email and login.' });
|
| 72 |
setMode('login');
|
| 73 |
} else if (mode === 'forgot') {
|
| 74 |
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
| 75 |
+
redirectTo: `${window.location.origin}/reset-password`,
|
| 76 |
});
|
| 77 |
if (error) throw error;
|
| 78 |
setNotification({ type: 'success', message: 'Password reset link sent! Check your email.' });
|
src/pages/ApplicantJobPage.jsx
CHANGED
|
@@ -33,6 +33,7 @@ export default function ApplicantJobPage({ onNavigate }) {
|
|
| 33 |
const [searchQuery, setSearchQuery] = useState('');
|
| 34 |
const [activeTab, setActiveTab] = useState('all');
|
| 35 |
const [loading, setLoading] = useState(true);
|
|
|
|
| 36 |
|
| 37 |
useEffect(() => {
|
| 38 |
const fetchData = async () => {
|
|
@@ -156,8 +157,14 @@ export default function ApplicantJobPage({ onNavigate }) {
|
|
| 156 |
job.company.toLowerCase().includes(lowerQuery)
|
| 157 |
);
|
| 158 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
setFilteredJobs(result);
|
| 160 |
-
}, [searchQuery, activeTab, allJobs, userProfile]);
|
| 161 |
|
| 162 |
return (
|
| 163 |
<ApplicantLayout activePage="applicant-jobs" onNavigate={onNavigate}>
|
|
@@ -167,8 +174,8 @@ export default function ApplicantJobPage({ onNavigate }) {
|
|
| 167 |
{activeTab === 'recommended' ? 'Jobs For You' : 'All Opportunities'}
|
| 168 |
</h2>
|
| 169 |
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)', padding: '0.25rem', borderRadius: '0.5rem', display: 'flex', gap: '0.5rem' }}>
|
| 170 |
-
<button onClick={() => setActiveTab('all')} style={{ padding: '0.5rem 1rem', borderRadius: '0.3rem', border: 'none', cursor: 'pointer', fontWeight: 'bold', backgroundColor: activeTab === 'all' ? '#FBBF24' : 'transparent', color: activeTab === 'all' ? '#1a202c' : '#9ca3af' }}>All Jobs</button>
|
| 171 |
-
<button onClick={() => setActiveTab('recommended')} style={{ padding: '0.5rem 1rem', borderRadius: '0.3rem', border: 'none', cursor: 'pointer', fontWeight: 'bold', backgroundColor: activeTab === 'recommended' ? '#FBBF24' : 'transparent', color: activeTab === 'recommended' ? '#1a202c' : '#9ca3af' }}>Recommended ✨</button>
|
| 172 |
</div>
|
| 173 |
</div>
|
| 174 |
|
|
@@ -194,7 +201,31 @@ export default function ApplicantJobPage({ onNavigate }) {
|
|
| 194 |
<button onClick={() => onNavigate('applicant-profile')} style={{ marginTop: '1rem', padding: '0.5rem 1rem', backgroundColor: '#374151', color: 'white', border: 'none', borderRadius: '0.5rem', cursor: 'pointer' }}>Update Profile</button>
|
| 195 |
</div>
|
| 196 |
) : (
|
| 197 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
)}
|
| 199 |
</>
|
| 200 |
)}
|
|
|
|
| 33 |
const [searchQuery, setSearchQuery] = useState('');
|
| 34 |
const [activeTab, setActiveTab] = useState('all');
|
| 35 |
const [loading, setLoading] = useState(true);
|
| 36 |
+
const [showAllJobs, setShowAllJobs] = useState(false); // ✅ Track if showing all jobs
|
| 37 |
|
| 38 |
useEffect(() => {
|
| 39 |
const fetchData = async () => {
|
|
|
|
| 157 |
job.company.toLowerCase().includes(lowerQuery)
|
| 158 |
);
|
| 159 |
}
|
| 160 |
+
|
| 161 |
+
// ✅ Limit to 4 jobs unless "See All" is clicked
|
| 162 |
+
if (!showAllJobs && !searchQuery) {
|
| 163 |
+
result = result.slice(0, 4);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
setFilteredJobs(result);
|
| 167 |
+
}, [searchQuery, activeTab, allJobs, userProfile, showAllJobs]);
|
| 168 |
|
| 169 |
return (
|
| 170 |
<ApplicantLayout activePage="applicant-jobs" onNavigate={onNavigate}>
|
|
|
|
| 174 |
{activeTab === 'recommended' ? 'Jobs For You' : 'All Opportunities'}
|
| 175 |
</h2>
|
| 176 |
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)', padding: '0.25rem', borderRadius: '0.5rem', display: 'flex', gap: '0.5rem' }}>
|
| 177 |
+
<button onClick={() => { setActiveTab('all'); setShowAllJobs(false); }} style={{ padding: '0.5rem 1rem', borderRadius: '0.3rem', border: 'none', cursor: 'pointer', fontWeight: 'bold', backgroundColor: activeTab === 'all' ? '#FBBF24' : 'transparent', color: activeTab === 'all' ? '#1a202c' : '#9ca3af' }}>All Jobs</button>
|
| 178 |
+
<button onClick={() => { setActiveTab('recommended'); setShowAllJobs(false); }} style={{ padding: '0.5rem 1rem', borderRadius: '0.3rem', border: 'none', cursor: 'pointer', fontWeight: 'bold', backgroundColor: activeTab === 'recommended' ? '#FBBF24' : 'transparent', color: activeTab === 'recommended' ? '#1a202c' : '#9ca3af' }}>Recommended ✨</button>
|
| 179 |
</div>
|
| 180 |
</div>
|
| 181 |
|
|
|
|
| 201 |
<button onClick={() => onNavigate('applicant-profile')} style={{ marginTop: '1rem', padding: '0.5rem 1rem', backgroundColor: '#374151', color: 'white', border: 'none', borderRadius: '0.5rem', cursor: 'pointer' }}>Update Profile</button>
|
| 202 |
</div>
|
| 203 |
) : (
|
| 204 |
+
<>
|
| 205 |
+
<JobListings searchQuery={searchQuery} setSearchQuery={setSearchQuery} isSearching={searchQuery.length > 0} filteredJobListings={filteredJobs} />
|
| 206 |
+
|
| 207 |
+
{/* ✅ See All Button */}
|
| 208 |
+
{!showAllJobs && !searchQuery && (
|
| 209 |
+
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '2rem' }}>
|
| 210 |
+
<button
|
| 211 |
+
onClick={() => setShowAllJobs(true)}
|
| 212 |
+
style={{
|
| 213 |
+
padding: '0.75rem 2rem',
|
| 214 |
+
backgroundColor: '#FBBF24',
|
| 215 |
+
color: '#1a202c',
|
| 216 |
+
border: 'none',
|
| 217 |
+
borderRadius: '0.5rem',
|
| 218 |
+
cursor: 'pointer',
|
| 219 |
+
fontWeight: 'bold',
|
| 220 |
+
fontSize: '1rem',
|
| 221 |
+
transition: 'all 0.2s'
|
| 222 |
+
}}
|
| 223 |
+
>
|
| 224 |
+
See All Jobs
|
| 225 |
+
</button>
|
| 226 |
+
</div>
|
| 227 |
+
)}
|
| 228 |
+
</>
|
| 229 |
)}
|
| 230 |
</>
|
| 231 |
)}
|
src/pages/ApplicantMessages.jsx
CHANGED
|
@@ -1,15 +1,30 @@
|
|
| 1 |
-
import React, { useState, useEffect } from 'react';
|
| 2 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
import { supabase } from '../supabaseClient';
|
| 4 |
import ApplicantLayout from '../components/ApplicantLayout';
|
| 5 |
import PlaceholderContent from '../components/PlaceholderContent';
|
| 6 |
import { ChatIcon, SearchIcon } from '../components/Icons';
|
| 7 |
|
| 8 |
-
|
| 9 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
const inputStyle = { width: '100%', padding: '0.8rem', background: 'transparent', border: 'none', color: 'white', outline: 'none', fontSize: '0.95rem' };
|
| 14 |
|
| 15 |
export default function ApplicantMessages({ onNavigate }) {
|
|
@@ -17,31 +32,183 @@ export default function ApplicantMessages({ onNavigate }) {
|
|
| 17 |
const [selected, setSelected] = useState(null);
|
| 18 |
const [text, setText] = useState('');
|
| 19 |
const [search, setSearch] = useState('');
|
|
|
|
|
|
|
|
|
|
| 20 |
|
|
|
|
| 21 |
useEffect(() => {
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
}, []);
|
| 35 |
|
| 36 |
-
const
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
setText('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
};
|
| 43 |
|
| 44 |
-
const filtered = threads.filter(t => t.name.toLowerCase().includes(search.toLowerCase())
|
| 45 |
|
| 46 |
return (
|
| 47 |
<ApplicantLayout activePage="applicant-messages" onNavigate={onNavigate}>
|
|
@@ -49,7 +216,7 @@ export default function ApplicantMessages({ onNavigate }) {
|
|
| 49 |
|
| 50 |
<div style={{ display: 'flex', height: 'calc(100vh - 140px)', gap: '1.5rem', paddingBottom: '1rem' }}>
|
| 51 |
|
| 52 |
-
{/*
|
| 53 |
<div style={{ ...glassStyle, width: '350px', flexShrink: 0 }}>
|
| 54 |
<div style={{ padding: '1.5rem' }}>
|
| 55 |
<h2 style={{ fontSize: '1.4rem', fontWeight: 'bold', marginBottom: '1rem' }}>Inbox</h2>
|
|
@@ -60,51 +227,105 @@ export default function ApplicantMessages({ onNavigate }) {
|
|
| 60 |
</div>
|
| 61 |
|
| 62 |
<div className="hide-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 0.75rem 1rem' }}>
|
| 63 |
-
{
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
</div>
|
| 79 |
</div>
|
| 80 |
|
| 81 |
-
{/*
|
| 82 |
<div style={{ ...glassStyle, flex: 1 }}>
|
| 83 |
{selected ? (
|
| 84 |
<>
|
| 85 |
-
{/* Chat Header */}
|
| 86 |
<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' }}>
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
</div>
|
| 90 |
|
| 91 |
-
{/* Chat History */}
|
| 92 |
<div className="hide-scroll" style={{ flex: 1, padding: '2rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
| 93 |
{selected.msgs.map(m => (
|
| 94 |
<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%' }}>
|
| 95 |
-
<div style={{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
{m.text}
|
| 97 |
</div>
|
| 98 |
<div style={{ fontSize: '0.7rem', color: '#64748b', marginTop: 4, textAlign: m.isMe ? 'right' : 'left' }}>{m.time}</div>
|
| 99 |
</motion.div>
|
| 100 |
))}
|
|
|
|
| 101 |
</div>
|
| 102 |
|
| 103 |
-
{/* Input Area */}
|
| 104 |
<div style={{ padding: '1.5rem', background: 'rgba(0,0,0,0.3)', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
| 105 |
<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)' }}>
|
| 106 |
-
<textarea
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
<SendIcon />
|
| 109 |
</button>
|
| 110 |
</div>
|
|
@@ -112,7 +333,7 @@ export default function ApplicantMessages({ onNavigate }) {
|
|
| 112 |
</>
|
| 113 |
) : (
|
| 114 |
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
| 115 |
-
<PlaceholderContent title="Your Messages" message="Select
|
| 116 |
</div>
|
| 117 |
)}
|
| 118 |
</div>
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
import { supabase } from '../supabaseClient';
|
| 4 |
import ApplicantLayout from '../components/ApplicantLayout';
|
| 5 |
import PlaceholderContent from '../components/PlaceholderContent';
|
| 6 |
import { ChatIcon, SearchIcon } from '../components/Icons';
|
| 7 |
|
| 8 |
+
// --- Inline UI Components for Chat ---
|
| 9 |
+
const SendIcon = () => (
|
| 10 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
| 11 |
+
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
| 12 |
+
</svg>
|
| 13 |
+
);
|
| 14 |
|
| 15 |
+
const Avatar = ({ name }) => (
|
| 16 |
+
<div style={{
|
| 17 |
+
width: 45, height: 45, borderRadius: '50%',
|
| 18 |
+
background: 'linear-gradient(135deg, #374151, #1f2937)',
|
| 19 |
+
border: '2px solid rgba(255,255,255,0.1)',
|
| 20 |
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
| 21 |
+
fontWeight: 'bold', color: 'white', flexShrink: 0
|
| 22 |
+
}}>
|
| 23 |
+
{name ? name[0].toUpperCase() : 'U'}
|
| 24 |
+
</div>
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
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' };
|
| 28 |
const inputStyle = { width: '100%', padding: '0.8rem', background: 'transparent', border: 'none', color: 'white', outline: 'none', fontSize: '0.95rem' };
|
| 29 |
|
| 30 |
export default function ApplicantMessages({ onNavigate }) {
|
|
|
|
| 32 |
const [selected, setSelected] = useState(null);
|
| 33 |
const [text, setText] = useState('');
|
| 34 |
const [search, setSearch] = useState('');
|
| 35 |
+
const [userId, setUserId] = useState(null);
|
| 36 |
+
const [loading, setLoading] = useState(true);
|
| 37 |
+
const scrollRef = useRef(null);
|
| 38 |
|
| 39 |
+
// Auto-scroll to bottom of conversation
|
| 40 |
useEffect(() => {
|
| 41 |
+
scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 42 |
+
}, [selected?.msgs]);
|
| 43 |
+
|
| 44 |
+
// ✅ FETCH MESSAGES AND NAMES
|
| 45 |
+
const fetchMsgs = async (uid) => {
|
| 46 |
+
try {
|
| 47 |
+
console.log("Fetching messages for user:", uid);
|
| 48 |
+
const { data: messages, error } = await supabase
|
| 49 |
+
.from('messages')
|
| 50 |
+
.select('*')
|
| 51 |
+
.or(`receiver_id.eq.${uid},sender_id.eq.${uid}`)
|
| 52 |
+
.order('created_at', { ascending: true });
|
| 53 |
+
|
| 54 |
+
if (error) throw error;
|
| 55 |
+
|
| 56 |
+
const threadMap = {};
|
| 57 |
+
messages.forEach(m => {
|
| 58 |
+
const isMe = m.sender_id === uid;
|
| 59 |
+
const otherId = isMe ? m.receiver_id : m.sender_id;
|
| 60 |
+
|
| 61 |
+
if (!threadMap[otherId]) {
|
| 62 |
+
threadMap[otherId] = {
|
| 63 |
+
id: otherId,
|
| 64 |
+
name: 'Admin / HR',
|
| 65 |
+
subj: 'Application Update',
|
| 66 |
+
last: '',
|
| 67 |
+
unread: false,
|
| 68 |
+
time: m.created_at,
|
| 69 |
+
msgs: [],
|
| 70 |
+
companyName: '',
|
| 71 |
+
companyLogo: '',
|
| 72 |
+
companyId: null
|
| 73 |
+
};
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
threadMap[otherId].msgs.push({
|
| 77 |
+
id: m.id,
|
| 78 |
+
text: m.content,
|
| 79 |
+
time: new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
| 80 |
+
isMe
|
| 81 |
+
});
|
| 82 |
+
threadMap[otherId].last = m.content;
|
| 83 |
+
threadMap[otherId].time = m.created_at;
|
| 84 |
+
if (!isMe && !m.is_read) threadMap[otherId].unread = true;
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
const threadList = Object.values(threadMap).sort((a, b) => new Date(b.time) - new Date(a.time));
|
| 88 |
+
|
| 89 |
+
// ✅ Fetch Admin Names and Company Info from 'user_roles' table
|
| 90 |
+
const otherUserIds = threadList.map(t => t.id);
|
| 91 |
+
if (otherUserIds.length > 0) {
|
| 92 |
+
const { data: rolesData } = await supabase
|
| 93 |
+
.from('user_roles')
|
| 94 |
+
.select('user_id, name, company_id')
|
| 95 |
+
.in('user_id', otherUserIds);
|
| 96 |
+
|
| 97 |
+
console.log("Roles Data:", rolesData);
|
| 98 |
+
|
| 99 |
+
if (rolesData && rolesData.length > 0) {
|
| 100 |
+
rolesData.forEach(role => {
|
| 101 |
+
const thread = threadList.find(t => t.id === role.user_id);
|
| 102 |
+
if (thread && role.name) thread.name = role.name;
|
| 103 |
+
if (thread) thread.companyId = role.company_id;
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
// ✅ Fetch Company Details including logo from storage
|
| 107 |
+
const companyIds = [...new Set(rolesData.map(r => r.company_id).filter(Boolean))];
|
| 108 |
+
console.log("Company IDs to fetch:", companyIds);
|
| 109 |
+
|
| 110 |
+
if (companyIds.length > 0) {
|
| 111 |
+
const { data: companiesData } = await supabase
|
| 112 |
+
.from('companies')
|
| 113 |
+
.select('id, name, logo_url')
|
| 114 |
+
.in('id', companyIds);
|
| 115 |
+
|
| 116 |
+
console.log("Companies Data:", companiesData);
|
| 117 |
+
|
| 118 |
+
if (companiesData) {
|
| 119 |
+
companiesData.forEach(company => {
|
| 120 |
+
threadList.forEach(thread => {
|
| 121 |
+
if (thread.companyId === company.id) {
|
| 122 |
+
thread.companyName = company.name;
|
| 123 |
+
thread.companyLogo = company.logo_url;
|
| 124 |
+
console.log(`Set company for thread ${thread.id}:`, company.name, company.logo_url);
|
| 125 |
+
}
|
| 126 |
+
});
|
| 127 |
+
});
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
} else {
|
| 131 |
+
console.log("No roles data found or rolesData is empty");
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
console.log("Final Thread List:", threadList);
|
| 136 |
+
setThreads(threadList);
|
| 137 |
+
|
| 138 |
+
// Refresh currently open chat window if data changed
|
| 139 |
+
if (selected) {
|
| 140 |
+
const updated = threadList.find(t => t.id === selected.id);
|
| 141 |
+
if (updated) setSelected(updated);
|
| 142 |
+
}
|
| 143 |
+
} catch (err) {
|
| 144 |
+
console.error("Message Fetch Error:", err);
|
| 145 |
+
} finally {
|
| 146 |
+
setLoading(false);
|
| 147 |
+
}
|
| 148 |
+
};
|
| 149 |
+
|
| 150 |
+
// ✅ REAL-TIME LISTENER
|
| 151 |
+
useEffect(() => {
|
| 152 |
+
let channel;
|
| 153 |
+
const init = async () => {
|
| 154 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 155 |
+
if (!user) return;
|
| 156 |
+
setUserId(user.id);
|
| 157 |
+
await fetchMsgs(user.id);
|
| 158 |
+
|
| 159 |
+
// Create a channel to listen for any new messages where THIS user is the receiver
|
| 160 |
+
channel = supabase.channel('applicant_inbox')
|
| 161 |
+
.on('postgres_changes', {
|
| 162 |
+
event: 'INSERT',
|
| 163 |
+
schema: 'public',
|
| 164 |
+
table: 'messages',
|
| 165 |
+
filter: `receiver_id=eq.${user.id}`
|
| 166 |
+
}, () => {
|
| 167 |
+
console.log("New message received from Admin!");
|
| 168 |
+
fetchMsgs(user.id);
|
| 169 |
+
})
|
| 170 |
+
.subscribe();
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
init();
|
| 174 |
+
return () => { if (channel) supabase.removeChannel(channel); };
|
| 175 |
}, []);
|
| 176 |
|
| 177 |
+
const markAsRead = async (t) => {
|
| 178 |
+
setSelected(t);
|
| 179 |
+
if (t.unread && userId) {
|
| 180 |
+
// Optimistic UI Update
|
| 181 |
+
setThreads(threads.map(x => x.id === t.id ? { ...x, unread: false } : x));
|
| 182 |
+
// Database Update
|
| 183 |
+
await supabase.from('messages')
|
| 184 |
+
.update({ is_read: true })
|
| 185 |
+
.eq('receiver_id', userId)
|
| 186 |
+
.eq('sender_id', t.id)
|
| 187 |
+
.eq('is_read', false);
|
| 188 |
+
}
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
const sendMsg = async () => {
|
| 192 |
+
if (!text.trim() || !selected || !userId) return;
|
| 193 |
+
const messageText = text.trim();
|
| 194 |
setText('');
|
| 195 |
+
|
| 196 |
+
// Send to Supabase
|
| 197 |
+
const { error } = await supabase.from('messages').insert([{
|
| 198 |
+
sender_id: userId,
|
| 199 |
+
receiver_id: selected.id,
|
| 200 |
+
content: messageText,
|
| 201 |
+
is_read: false
|
| 202 |
+
}]);
|
| 203 |
+
|
| 204 |
+
if (error) {
|
| 205 |
+
console.error("Send Error:", error);
|
| 206 |
+
} else {
|
| 207 |
+
fetchMsgs(userId); // Refresh to show my sent message
|
| 208 |
+
}
|
| 209 |
};
|
| 210 |
|
| 211 |
+
const filtered = threads.filter(t => t.name.toLowerCase().includes(search.toLowerCase()));
|
| 212 |
|
| 213 |
return (
|
| 214 |
<ApplicantLayout activePage="applicant-messages" onNavigate={onNavigate}>
|
|
|
|
| 216 |
|
| 217 |
<div style={{ display: 'flex', height: 'calc(100vh - 140px)', gap: '1.5rem', paddingBottom: '1rem' }}>
|
| 218 |
|
| 219 |
+
{/* --- CONVERSATIONS LIST --- */}
|
| 220 |
<div style={{ ...glassStyle, width: '350px', flexShrink: 0 }}>
|
| 221 |
<div style={{ padding: '1.5rem' }}>
|
| 222 |
<h2 style={{ fontSize: '1.4rem', fontWeight: 'bold', marginBottom: '1rem' }}>Inbox</h2>
|
|
|
|
| 227 |
</div>
|
| 228 |
|
| 229 |
<div className="hide-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 0.75rem 1rem' }}>
|
| 230 |
+
{loading ? (
|
| 231 |
+
<div style={{ padding: '2rem', textAlign: 'center', color: '#64748b' }}>Loading...</div>
|
| 232 |
+
) : filtered.length === 0 ? (
|
| 233 |
+
<div style={{ padding: '2rem', textAlign: 'center', color: '#64748b' }}>No messages found.</div>
|
| 234 |
+
) : (
|
| 235 |
+
filtered.map(t => {
|
| 236 |
+
const isAct = selected?.id === t.id;
|
| 237 |
+
return (
|
| 238 |
+
<motion.div
|
| 239 |
+
key={t.id} onClick={() => markAsRead(t)}
|
| 240 |
+
whileHover={{ scale: 0.98 }}
|
| 241 |
+
style={{
|
| 242 |
+
padding: '1.25rem', marginBottom: '0.5rem', borderRadius: '1rem',
|
| 243 |
+
cursor: 'pointer', position: 'relative',
|
| 244 |
+
background: isAct ? 'rgba(251, 191, 36, 0.08)' : 'transparent',
|
| 245 |
+
border: isAct ? '1px solid rgba(251,191,36,0.3)' : '1px solid transparent',
|
| 246 |
+
display: 'flex', gap: '1rem'
|
| 247 |
+
}}
|
| 248 |
+
>
|
| 249 |
+
{t.unread && !isAct && <div style={{ position: 'absolute', top: '1.5rem', right: '1.25rem', width: '10px', height: '10px', backgroundColor: '#FBBF24', borderRadius: '50%' }}></div>}
|
| 250 |
+
|
| 251 |
+
{/* Company Logo */}
|
| 252 |
+
{t.companyLogo ? (
|
| 253 |
+
<img
|
| 254 |
+
src={t.companyLogo}
|
| 255 |
+
alt={t.companyName}
|
| 256 |
+
style={{ width: 48, height: 48, borderRadius: '0.5rem', objectFit: 'cover', flexShrink: 0, border: '1px solid rgba(255,255,255,0.2)' }}
|
| 257 |
+
/>
|
| 258 |
+
) : (
|
| 259 |
+
<Avatar name={t.companyName || t.name} />
|
| 260 |
+
)}
|
| 261 |
+
|
| 262 |
+
{/* Message Info */}
|
| 263 |
+
<div style={{ flex: 1, minWidth: 0 }}>
|
| 264 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
| 265 |
+
<span style={{ fontWeight: 'bold', color: isAct ? '#FCD34D' : 'white', fontSize: '0.95rem' }}>{t.companyName || t.name}</span>
|
| 266 |
+
<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>
|
| 267 |
+
<span style={{ fontSize: '0.7rem', color: t.unread ? '#FCD34D' : '#64748b', marginLeft: 'auto', flexShrink: 0 }}>{new Date(t.time).toLocaleDateString([],{month:'short', day:'numeric'})}</span>
|
| 268 |
+
</div>
|
| 269 |
+
<div style={{ fontSize: '0.8rem', color: '#e2e8f0', margin: '0.2rem 0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t.subj}</div>
|
| 270 |
+
<div style={{ fontSize: '0.75rem', color: '#94a3b8', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t.last}</div>
|
| 271 |
+
</div>
|
| 272 |
+
</motion.div>
|
| 273 |
+
);
|
| 274 |
+
})
|
| 275 |
+
)}
|
| 276 |
</div>
|
| 277 |
</div>
|
| 278 |
|
| 279 |
+
{/* --- ACTIVE CONVERSATION --- */}
|
| 280 |
<div style={{ ...glassStyle, flex: 1 }}>
|
| 281 |
{selected ? (
|
| 282 |
<>
|
|
|
|
| 283 |
<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' }}>
|
| 284 |
+
{selected.companyLogo ? (
|
| 285 |
+
<img
|
| 286 |
+
src={selected.companyLogo}
|
| 287 |
+
alt={selected.companyName}
|
| 288 |
+
style={{ width: 45, height: 45, borderRadius: '0.5rem', objectFit: 'cover', border: '2px solid rgba(255,255,255,0.2)', flexShrink: 0 }}
|
| 289 |
+
/>
|
| 290 |
+
) : (
|
| 291 |
+
<Avatar name={selected.companyName || selected.name} />
|
| 292 |
+
)}
|
| 293 |
+
<div style={{ flex: 1 }}>
|
| 294 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
| 295 |
+
<h3 style={{ margin: 0, color: 'white' }}>{selected.companyName || selected.name}</h3>
|
| 296 |
+
<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>
|
| 297 |
+
</div>
|
| 298 |
+
<p style={{ margin: 0, fontSize: '0.85rem', color: '#10b981' }}>Active Now</p>
|
| 299 |
+
</div>
|
| 300 |
</div>
|
| 301 |
|
|
|
|
| 302 |
<div className="hide-scroll" style={{ flex: 1, padding: '2rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
| 303 |
{selected.msgs.map(m => (
|
| 304 |
<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%' }}>
|
| 305 |
+
<div style={{
|
| 306 |
+
background: m.isMe ? 'linear-gradient(135deg, #FBBF24, #F59E0B)' : 'rgba(255,255,255,0.06)',
|
| 307 |
+
color: m.isMe ? '#020617' : 'white',
|
| 308 |
+
padding: '1rem 1.25rem', borderRadius: '1.25rem',
|
| 309 |
+
borderBottomRightRadius: m.isMe ? 4 : '1.25rem', borderBottomLeftRadius: m.isMe ? '1.25rem' : 4
|
| 310 |
+
}}>
|
| 311 |
{m.text}
|
| 312 |
</div>
|
| 313 |
<div style={{ fontSize: '0.7rem', color: '#64748b', marginTop: 4, textAlign: m.isMe ? 'right' : 'left' }}>{m.time}</div>
|
| 314 |
</motion.div>
|
| 315 |
))}
|
| 316 |
+
<div ref={scrollRef} />
|
| 317 |
</div>
|
| 318 |
|
|
|
|
| 319 |
<div style={{ padding: '1.5rem', background: 'rgba(0,0,0,0.3)', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
| 320 |
<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)' }}>
|
| 321 |
+
<textarea
|
| 322 |
+
value={text}
|
| 323 |
+
onChange={e => setText(e.target.value)}
|
| 324 |
+
onKeyDown={e => { if(e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); sendMsg(); }}}
|
| 325 |
+
placeholder="Type your reply..."
|
| 326 |
+
style={{ ...inputStyle, resize: 'none', height: '45px', fontFamily: 'inherit' }}
|
| 327 |
+
/>
|
| 328 |
+
<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' }}>
|
| 329 |
<SendIcon />
|
| 330 |
</button>
|
| 331 |
</div>
|
|
|
|
| 333 |
</>
|
| 334 |
) : (
|
| 335 |
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
| 336 |
+
<PlaceholderContent title="Your Messages" message="Select an Admin to start chatting." icon={<ChatIcon />} />
|
| 337 |
</div>
|
| 338 |
)}
|
| 339 |
</div>
|
src/pages/ResetPassword.jsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState } from "react";
|
| 3 |
+
import { supabase } from "../supabaseClient";
|
| 4 |
+
|
| 5 |
+
export default function ResetPassword() {
|
| 6 |
+
|
| 7 |
+
const [password, setPassword] = useState("");
|
| 8 |
+
const [loading, setLoading] = useState(false);
|
| 9 |
+
|
| 10 |
+
const handleUpdate = async (e) => {
|
| 11 |
+
e.preventDefault();
|
| 12 |
+
setLoading(true);
|
| 13 |
+
|
| 14 |
+
const { error } = await supabase.auth.updateUser({
|
| 15 |
+
password: password
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
if (error) {
|
| 19 |
+
alert(error.message);
|
| 20 |
+
} else {
|
| 21 |
+
alert("Password updated successfully!");
|
| 22 |
+
window.location.href = "/";
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
setLoading(false);
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<div style={{padding:"40px", color:"white"}}>
|
| 30 |
+
|
| 31 |
+
<h2>Reset Your Password</h2>
|
| 32 |
+
|
| 33 |
+
<form onSubmit={handleUpdate}>
|
| 34 |
+
<input
|
| 35 |
+
type="password"
|
| 36 |
+
placeholder="Enter new password"
|
| 37 |
+
value={password}
|
| 38 |
+
onChange={(e)=>setPassword(e.target.value)}
|
| 39 |
+
style={{padding:"10px", margin:"10px"}}
|
| 40 |
+
/>
|
| 41 |
+
|
| 42 |
+
<button type="submit">
|
| 43 |
+
{loading ? "Updating..." : "Update Password"}
|
| 44 |
+
</button>
|
| 45 |
+
|
| 46 |
+
</form>
|
| 47 |
+
|
| 48 |
+
</div>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|