Spaces:
Sleeping
Sleeping
Muhammed Sameer commited on
Commit ·
6e5bfbf
1
Parent(s): ff85727
Initial commit for hosting
Browse files- backend/src/services/ats_service.py +1 -0
- src/components/ApplicantLayout.jsx +316 -19
- src/components/Icons.jsx +5 -0
- src/components/JobCard.jsx +16 -6
- src/components/JobDetail.jsx +15 -6
- src/components/ResumeBuilder.jsx +437 -0
- src/index.css +50 -0
- src/pages/ApplicantATS.jsx +29 -1
- src/pages/ApplicantJobPage.jsx +11 -11
backend/src/services/ats_service.py
CHANGED
|
@@ -145,6 +145,7 @@ def calculate_ats_score(resume_data: dict, job_data: dict) -> dict:
|
|
| 145 |
"matches": matches,
|
| 146 |
"summary": summary,
|
| 147 |
"recommendations": recommendations,
|
|
|
|
| 148 |
"debug_resume_skills": list(resume_skills), # Helpful for debugging
|
| 149 |
"debug_job_skills": list(job_skills)
|
| 150 |
}
|
|
|
|
| 145 |
"matches": matches,
|
| 146 |
"summary": summary,
|
| 147 |
"recommendations": recommendations,
|
| 148 |
+
"resume_data": resume_data, # Added to pre-fill the Resume Builder
|
| 149 |
"debug_resume_skills": list(resume_skills), # Helpful for debugging
|
| 150 |
"debug_job_skills": list(job_skills)
|
| 151 |
}
|
src/components/ApplicantLayout.jsx
CHANGED
|
@@ -4,7 +4,7 @@ import { supabase } from '../supabaseClient';
|
|
| 4 |
import {
|
| 5 |
LogoutIcon, BriefcaseIcon, UserCircleIcon, ChatIcon,
|
| 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">
|
|
@@ -19,12 +19,23 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 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)) {
|
|
@@ -45,7 +56,7 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 45 |
.select('full_name')
|
| 46 |
.eq('id', user.id)
|
| 47 |
.maybeSingle();
|
| 48 |
-
|
| 49 |
if (profile && profile.full_name) {
|
| 50 |
const firstName = profile.full_name.split(' ')[0];
|
| 51 |
setUserName(firstName);
|
|
@@ -89,6 +100,30 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 89 |
|
| 90 |
fetchUnreadMessages();
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
}, []);
|
| 93 |
|
| 94 |
// ⭐ NEW: realtime update for new messages
|
|
@@ -100,6 +135,8 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 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")
|
|
@@ -116,12 +153,129 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 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 |
}, []);
|
|
@@ -159,6 +313,91 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 159 |
fetchNotifications();
|
| 160 |
}, [activePage]);
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
// ⭐ NEW: mark messages read when user opens messages page
|
| 163 |
useEffect(() => {
|
| 164 |
|
|
@@ -180,6 +419,10 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 180 |
|
| 181 |
markMessagesRead();
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
}, [activePage]);
|
| 184 |
|
| 185 |
const handleLogout = async () => {
|
|
@@ -200,20 +443,23 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 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
|
| 214 |
whileHover={{ scale: 1.05 }}
|
| 215 |
whileTap={{ scale: 0.95 }}
|
| 216 |
-
onClick={() =>
|
|
|
|
|
|
|
|
|
|
| 217 |
style={{
|
| 218 |
background: 'rgba(255,255,255,0.05)',
|
| 219 |
border: '1px solid rgba(255,255,255,0.1)',
|
|
@@ -225,7 +471,7 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 225 |
}}
|
| 226 |
>
|
| 227 |
<BellIcon />
|
| 228 |
-
{(notifications.length + unreadMessages) > 0 && (
|
| 229 |
<span style={{
|
| 230 |
position: 'absolute',
|
| 231 |
top: '-5px',
|
|
@@ -242,7 +488,7 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 242 |
fontSize: '0.75rem',
|
| 243 |
fontWeight: 'bold'
|
| 244 |
}}>
|
| 245 |
-
{notifications.length + unreadMessages}
|
| 246 |
</span>
|
| 247 |
)}
|
| 248 |
</motion.button>
|
|
@@ -267,10 +513,10 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 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>
|
|
@@ -305,6 +551,22 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 305 |
</div>
|
| 306 |
</div>
|
| 307 |
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
</>
|
| 309 |
)}
|
| 310 |
</div>
|
|
@@ -314,10 +576,10 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 314 |
</div>
|
| 315 |
)}
|
| 316 |
|
| 317 |
-
<motion.button
|
| 318 |
-
onClick={handleLogout}
|
| 319 |
-
whileHover={{ scale: 1.03 }}
|
| 320 |
-
whileTap={{ scale: 0.98 }}
|
| 321 |
style={{ backgroundColor: '#FBBF24', color: '#1a202c', display: 'flex', alignItems: 'center', padding: '0.75rem', borderRadius: '0.5rem', fontWeight: 'bold', cursor: 'pointer', border: 'none' }}
|
| 322 |
>
|
| 323 |
<LogoutIcon />
|
|
@@ -331,9 +593,9 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 331 |
{navItems.map(({ key, icon, label }) => {
|
| 332 |
const active = isActive(key);
|
| 333 |
return (
|
| 334 |
-
<div
|
| 335 |
-
key={key}
|
| 336 |
-
onClick={() => onNavigate(key)}
|
| 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}
|
|
@@ -357,9 +619,44 @@ export default function ApplicantLayout({ children, activePage, onNavigate }) {
|
|
| 357 |
</nav>
|
| 358 |
</div>
|
| 359 |
|
| 360 |
-
<main style={{ position: 'relative', zIndex: 1, flex: 1, overflowY: 'auto' }}>
|
| 361 |
{children}
|
| 362 |
</main>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
</div>
|
| 364 |
);
|
| 365 |
}
|
|
|
|
| 4 |
import {
|
| 5 |
LogoutIcon, BriefcaseIcon, UserCircleIcon, ChatIcon,
|
| 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">
|
|
|
|
| 19 |
|
| 20 |
const [notifications, setNotifications] = useState([]);
|
| 21 |
const [showNotifications, setShowNotifications] = useState(false);
|
| 22 |
+
const [hasSeenNotifs, setHasSeenNotifs] = useState(false);
|
| 23 |
const notifRef = useRef(null);
|
| 24 |
|
|
|
|
| 25 |
const [unreadMessages, setUnreadMessages] = useState(0);
|
| 26 |
const [unreadMessagesList, setUnreadMessagesList] = useState([]);
|
| 27 |
|
| 28 |
+
// ⭐ NEW: recent jobs notifications
|
| 29 |
+
const [newJobs, setNewJobs] = useState([]);
|
| 30 |
+
|
| 31 |
+
// ⭐ NEW: toast notification state
|
| 32 |
+
const [toastNotif, setToastNotif] = useState(null);
|
| 33 |
+
|
| 34 |
+
const triggerToast = (title, message, icon) => {
|
| 35 |
+
setToastNotif({ id: Date.now(), title, message, icon });
|
| 36 |
+
setTimeout(() => setToastNotif(null), 5000); // Hide after 5 seconds
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
useEffect(() => {
|
| 40 |
function handleClickOutside(event) {
|
| 41 |
if (notifRef.current && !notifRef.current.contains(event.target)) {
|
|
|
|
| 56 |
.select('full_name')
|
| 57 |
.eq('id', user.id)
|
| 58 |
.maybeSingle();
|
| 59 |
+
|
| 60 |
if (profile && profile.full_name) {
|
| 61 |
const firstName = profile.full_name.split(' ')[0];
|
| 62 |
setUserName(firstName);
|
|
|
|
| 100 |
|
| 101 |
fetchUnreadMessages();
|
| 102 |
|
| 103 |
+
const fetchRecentJobs = async () => {
|
| 104 |
+
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
| 105 |
+
const { data: recentJobs } = await supabase
|
| 106 |
+
.from('jobs')
|
| 107 |
+
.select('id, title, company_id, created_at, companies ( name )')
|
| 108 |
+
.eq('status', 'Active')
|
| 109 |
+
.gte('created_at', oneDayAgo)
|
| 110 |
+
.order('created_at', { ascending: false })
|
| 111 |
+
.limit(3);
|
| 112 |
+
|
| 113 |
+
if (recentJobs) {
|
| 114 |
+
const jobNotifs = recentJobs.map(job => ({
|
| 115 |
+
id: `job-${job.id}`,
|
| 116 |
+
title: 'New Job Posted',
|
| 117 |
+
text: `${job.companies?.name || 'A company'} is hiring: ${job.title}`,
|
| 118 |
+
time: new Date(job.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
| 119 |
+
color: '#8b5cf6' // Purple color for jobs
|
| 120 |
+
}));
|
| 121 |
+
setNewJobs(jobNotifs);
|
| 122 |
+
}
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
fetchRecentJobs();
|
| 126 |
+
|
| 127 |
}, []);
|
| 128 |
|
| 129 |
// ⭐ NEW: realtime update for new messages
|
|
|
|
| 135 |
"postgres_changes",
|
| 136 |
{ event: "INSERT", schema: "public", table: "messages" },
|
| 137 |
async (payload) => {
|
| 138 |
+
console.log("=== MESSAGES PAYLOAD ===", payload);
|
| 139 |
+
|
| 140 |
// Fetch the sender's name and add to the list
|
| 141 |
const { data: senderProfile } = await supabase
|
| 142 |
.from("profiles")
|
|
|
|
| 153 |
|
| 154 |
setUnreadMessagesList(prev => [newMsg, ...prev]);
|
| 155 |
setUnreadMessages(prev => prev + 1);
|
| 156 |
+
|
| 157 |
+
// Trigger realtime toast popup
|
| 158 |
+
triggerToast(
|
| 159 |
+
'New Message Received',
|
| 160 |
+
`${senderProfile?.full_name || 'Someone'} sent you a message`,
|
| 161 |
+
'📨'
|
| 162 |
+
);
|
| 163 |
+
}
|
| 164 |
+
)
|
| 165 |
+
.subscribe();
|
| 166 |
+
|
| 167 |
+
const jobsChannel = supabase
|
| 168 |
+
.channel("jobs-badge")
|
| 169 |
+
.on(
|
| 170 |
+
"postgres_changes",
|
| 171 |
+
{ event: "INSERT", schema: "public", table: "jobs", filter: "status=eq.Active" },
|
| 172 |
+
async (payload) => {
|
| 173 |
+
console.log("=== JOBS PAYLOAD ===", payload);
|
| 174 |
+
|
| 175 |
+
let companyName = 'A company';
|
| 176 |
+
if (payload.new.company_id) {
|
| 177 |
+
const { data: comp } = await supabase
|
| 178 |
+
.from("companies")
|
| 179 |
+
.select("name")
|
| 180 |
+
.eq("id", payload.new.company_id)
|
| 181 |
+
.maybeSingle();
|
| 182 |
+
if (comp) companyName = comp.name;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
const newJobNotif = {
|
| 186 |
+
id: `job-${payload.new.id}`,
|
| 187 |
+
title: 'New Job Posted',
|
| 188 |
+
text: `${companyName} is hiring: ${payload.new.title}`,
|
| 189 |
+
time: new Date(payload.new.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
| 190 |
+
color: '#8b5cf6'
|
| 191 |
+
};
|
| 192 |
+
|
| 193 |
+
setNewJobs(prev => [newJobNotif, ...prev]);
|
| 194 |
+
|
| 195 |
+
// Trigger realtime toast popup
|
| 196 |
+
triggerToast(
|
| 197 |
+
'New Job Posted',
|
| 198 |
+
`${companyName} is hiring: ${payload.new.title}`,
|
| 199 |
+
'💼'
|
| 200 |
+
);
|
| 201 |
+
}
|
| 202 |
+
)
|
| 203 |
+
.subscribe();
|
| 204 |
+
|
| 205 |
+
const appsChannel = supabase
|
| 206 |
+
.channel("interviews-badge-applicant") // unique channel name
|
| 207 |
+
.on(
|
| 208 |
+
"postgres_changes",
|
| 209 |
+
{ event: "*", schema: "public", table: "interviews" },
|
| 210 |
+
async (payload) => {
|
| 211 |
+
console.log("=== INTERVIEWS PAYLOAD ===", payload);
|
| 212 |
+
|
| 213 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 214 |
+
if (!user || !payload.new) return;
|
| 215 |
+
|
| 216 |
+
// Interviews don't have user_id on them directly. They have application_id.
|
| 217 |
+
// We must fetch the application to verify if it belongs to this user.
|
| 218 |
+
const { data: appData } = await supabase
|
| 219 |
+
.from('applications')
|
| 220 |
+
.select('user_id, job_id')
|
| 221 |
+
.eq('id', payload.new.application_id)
|
| 222 |
+
.maybeSingle();
|
| 223 |
+
|
| 224 |
+
if (!appData || appData.user_id !== user.id) {
|
| 225 |
+
return; // Not this user's interview
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// For an insert OR an update where status is Scheduled
|
| 229 |
+
if (payload.new.status === 'Scheduled') {
|
| 230 |
+
|
| 231 |
+
// If it's an UPDATE, optionally ensure it actually CHANGED to Scheduled
|
| 232 |
+
if (payload.eventType === 'UPDATE') {
|
| 233 |
+
if (payload.old && payload.old.status === 'Scheduled') {
|
| 234 |
+
return;
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
console.log(">>> TRIGGERING INTERVIEW NOTIFICATION! <<<");
|
| 239 |
+
|
| 240 |
+
const { data: job } = await supabase
|
| 241 |
+
.from('jobs')
|
| 242 |
+
.select('title')
|
| 243 |
+
.eq('id', appData.job_id)
|
| 244 |
+
.maybeSingle();
|
| 245 |
+
|
| 246 |
+
const jobTitle = job?.title || 'a job';
|
| 247 |
+
|
| 248 |
+
let timeLabel = '';
|
| 249 |
+
if (payload.new.scheduled_time) {
|
| 250 |
+
const dateObj = new Date(payload.new.scheduled_time);
|
| 251 |
+
timeLabel = ` on ${dateObj.toLocaleDateString()} at ${dateObj.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
triggerToast(
|
| 255 |
+
'Interview Scheduled! 📅',
|
| 256 |
+
`You have been invited to interview for ${jobTitle}${timeLabel}`,
|
| 257 |
+
'🎉'
|
| 258 |
+
);
|
| 259 |
+
|
| 260 |
+
setHasSeenNotifs(false);
|
| 261 |
+
|
| 262 |
+
const newNotif = {
|
| 263 |
+
id: Date.now(),
|
| 264 |
+
title: `Interview Scheduled`,
|
| 265 |
+
text: `You have an upcoming interview for ${jobTitle}.`,
|
| 266 |
+
time: new Date().toISOString(),
|
| 267 |
+
color: '#3B82F6'
|
| 268 |
+
};
|
| 269 |
+
setNotifications(prev => [newNotif, ...prev]);
|
| 270 |
+
}
|
| 271 |
}
|
| 272 |
)
|
| 273 |
.subscribe();
|
| 274 |
|
| 275 |
return () => {
|
| 276 |
supabase.removeChannel(channel);
|
| 277 |
+
supabase.removeChannel(jobsChannel);
|
| 278 |
+
supabase.removeChannel(appsChannel);
|
| 279 |
};
|
| 280 |
|
| 281 |
}, []);
|
|
|
|
| 313 |
fetchNotifications();
|
| 314 |
}, [activePage]);
|
| 315 |
|
| 316 |
+
// ⭐ NEW: specific timing interview notifications
|
| 317 |
+
useEffect(() => {
|
| 318 |
+
let interviewInterval;
|
| 319 |
+
|
| 320 |
+
const checkInterviews = async () => {
|
| 321 |
+
try {
|
| 322 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 323 |
+
if (!user) return;
|
| 324 |
+
|
| 325 |
+
const { data: ints } = await supabase
|
| 326 |
+
.from('interviews')
|
| 327 |
+
.select('id, scheduled_time, created_at, applications!inner( jobs(title) )')
|
| 328 |
+
.eq('applications.user_id', user.id)
|
| 329 |
+
.eq('status', 'Scheduled');
|
| 330 |
+
|
| 331 |
+
if (ints && ints.length > 0) {
|
| 332 |
+
const now = new Date();
|
| 333 |
+
|
| 334 |
+
ints.forEach(intObj => {
|
| 335 |
+
if (!intObj.scheduled_time) return;
|
| 336 |
+
|
| 337 |
+
const interviewDate = new Date(intObj.scheduled_time);
|
| 338 |
+
if (isNaN(interviewDate.getTime())) return;
|
| 339 |
+
|
| 340 |
+
const diffMs = interviewDate - now;
|
| 341 |
+
const diffHours = diffMs / (1000 * 60 * 60);
|
| 342 |
+
|
| 343 |
+
const ONE_WEEK = 7 * 24;
|
| 344 |
+
const TWO_DAYS = 2 * 24;
|
| 345 |
+
const TWENTY_FOUR_HOURS = 24;
|
| 346 |
+
const FIVE_HOURS = 5;
|
| 347 |
+
|
| 348 |
+
const windowHours = 0.5;
|
| 349 |
+
|
| 350 |
+
let triggered = false;
|
| 351 |
+
let timeLabel = '';
|
| 352 |
+
|
| 353 |
+
if (diffHours > 0) {
|
| 354 |
+
if (Math.abs(diffHours - ONE_WEEK) <= windowHours) {
|
| 355 |
+
triggered = true;
|
| 356 |
+
timeLabel = 'in 1 week';
|
| 357 |
+
} else if (Math.abs(diffHours - TWO_DAYS) <= windowHours) {
|
| 358 |
+
triggered = true;
|
| 359 |
+
timeLabel = 'in 2 days';
|
| 360 |
+
} else if (Math.abs(diffHours - TWENTY_FOUR_HOURS) <= windowHours) {
|
| 361 |
+
triggered = true;
|
| 362 |
+
timeLabel = 'in 24 hours';
|
| 363 |
+
} else if (Math.abs(diffHours - FIVE_HOURS) <= windowHours) {
|
| 364 |
+
triggered = true;
|
| 365 |
+
timeLabel = 'in 5 hours';
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
// Just scheduled within last 30 minutes
|
| 370 |
+
const updatedDate = new Date(intObj.created_at);
|
| 371 |
+
const updatedDiffHours = (now - updatedDate) / (1000 * 60 * 60);
|
| 372 |
+
if (updatedDiffHours <= windowHours) {
|
| 373 |
+
triggered = true;
|
| 374 |
+
timeLabel = 'recently scheduled';
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
if (triggered) {
|
| 378 |
+
const jobTitle = intObj.applications?.jobs?.title || 'a job';
|
| 379 |
+
triggerToast(
|
| 380 |
+
'Interview Reminder! 📅',
|
| 381 |
+
`You have an interview ${timeLabel} for: ${jobTitle}`,
|
| 382 |
+
'⏰'
|
| 383 |
+
);
|
| 384 |
+
}
|
| 385 |
+
});
|
| 386 |
+
}
|
| 387 |
+
} catch (error) {
|
| 388 |
+
console.error("Error checking interviews:", error);
|
| 389 |
+
}
|
| 390 |
+
};
|
| 391 |
+
|
| 392 |
+
// Check immediately on load
|
| 393 |
+
checkInterviews();
|
| 394 |
+
|
| 395 |
+
// Check every 30 minutes (30 * 60 * 1000 ms)
|
| 396 |
+
interviewInterval = setInterval(checkInterviews, 1800000);
|
| 397 |
+
|
| 398 |
+
return () => clearInterval(interviewInterval);
|
| 399 |
+
}, []);
|
| 400 |
+
|
| 401 |
// ⭐ NEW: mark messages read when user opens messages page
|
| 402 |
useEffect(() => {
|
| 403 |
|
|
|
|
| 419 |
|
| 420 |
markMessagesRead();
|
| 421 |
|
| 422 |
+
if (activePage === "applicant-jobs") {
|
| 423 |
+
setNewJobs([]); // Clear job notifications when jobs page is opened
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
}, [activePage]);
|
| 427 |
|
| 428 |
const handleLogout = async () => {
|
|
|
|
| 443 |
|
| 444 |
return (
|
| 445 |
<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' }}>
|
| 446 |
+
|
| 447 |
<header style={{ position: 'relative', zIndex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexShrink: 0 }}>
|
| 448 |
<h1 style={{ fontSize: '1.875rem', fontWeight: 'bold' }}>
|
| 449 |
{userName ? `Hi, ${userName} 👋` : 'Welcome 👋'}
|
| 450 |
</h1>
|
| 451 |
|
| 452 |
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
| 453 |
+
|
| 454 |
{activePage === 'applicant-profile' && (
|
| 455 |
<div style={{ position: 'relative' }} ref={notifRef}>
|
| 456 |
+
<motion.button
|
| 457 |
whileHover={{ scale: 1.05 }}
|
| 458 |
whileTap={{ scale: 0.95 }}
|
| 459 |
+
onClick={() => {
|
| 460 |
+
setShowNotifications(!showNotifications);
|
| 461 |
+
if (!showNotifications) setHasSeenNotifs(true);
|
| 462 |
+
}}
|
| 463 |
style={{
|
| 464 |
background: 'rgba(255,255,255,0.05)',
|
| 465 |
border: '1px solid rgba(255,255,255,0.1)',
|
|
|
|
| 471 |
}}
|
| 472 |
>
|
| 473 |
<BellIcon />
|
| 474 |
+
{!hasSeenNotifs && (notifications.length + unreadMessages + newJobs.length) > 0 && (
|
| 475 |
<span style={{
|
| 476 |
position: 'absolute',
|
| 477 |
top: '-5px',
|
|
|
|
| 488 |
fontSize: '0.75rem',
|
| 489 |
fontWeight: 'bold'
|
| 490 |
}}>
|
| 491 |
+
{notifications.length + unreadMessages + newJobs.length}
|
| 492 |
</span>
|
| 493 |
)}
|
| 494 |
</motion.button>
|
|
|
|
| 513 |
}}
|
| 514 |
>
|
| 515 |
<div style={{ padding: '1rem', fontWeight: 'bold', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
|
| 516 |
+
Recent Notification ({notifications.length + unreadMessages + newJobs.length})
|
| 517 |
</div>
|
| 518 |
<div style={{ padding: '0.75rem' }}>
|
| 519 |
+
{notifications.length + unreadMessages + newJobs.length === 0 ? (
|
| 520 |
<div style={{ padding: '1rem', textAlign: 'center', color: '#94a3b8', fontSize: '0.85rem' }}>
|
| 521 |
No new notifications
|
| 522 |
</div>
|
|
|
|
| 551 |
</div>
|
| 552 |
</div>
|
| 553 |
))}
|
| 554 |
+
{newJobs.map(jobNotif => (
|
| 555 |
+
<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' }}>
|
| 556 |
+
<span style={{ color: jobNotif.color, fontSize: '1rem' }}>💼</span>
|
| 557 |
+
<div style={{ flex: 1 }}>
|
| 558 |
+
<p style={{ margin: 0, fontSize: '0.85rem', color: '#e2e8f0', fontWeight: 500 }}>
|
| 559 |
+
{jobNotif.title}
|
| 560 |
+
</p>
|
| 561 |
+
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.75rem', color: '#cbd5e1' }}>
|
| 562 |
+
{jobNotif.text}
|
| 563 |
+
</p>
|
| 564 |
+
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.7rem', color: '#64748b' }}>
|
| 565 |
+
{jobNotif.time}
|
| 566 |
+
</p>
|
| 567 |
+
</div>
|
| 568 |
+
</div>
|
| 569 |
+
))}
|
| 570 |
</>
|
| 571 |
)}
|
| 572 |
</div>
|
|
|
|
| 576 |
</div>
|
| 577 |
)}
|
| 578 |
|
| 579 |
+
<motion.button
|
| 580 |
+
onClick={handleLogout}
|
| 581 |
+
whileHover={{ scale: 1.03 }}
|
| 582 |
+
whileTap={{ scale: 0.98 }}
|
| 583 |
style={{ backgroundColor: '#FBBF24', color: '#1a202c', display: 'flex', alignItems: 'center', padding: '0.75rem', borderRadius: '0.5rem', fontWeight: 'bold', cursor: 'pointer', border: 'none' }}
|
| 584 |
>
|
| 585 |
<LogoutIcon />
|
|
|
|
| 593 |
{navItems.map(({ key, icon, label }) => {
|
| 594 |
const active = isActive(key);
|
| 595 |
return (
|
| 596 |
+
<div
|
| 597 |
+
key={key}
|
| 598 |
+
onClick={() => onNavigate(key)}
|
| 599 |
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 }}
|
| 600 |
>
|
| 601 |
{icon}
|
|
|
|
| 619 |
</nav>
|
| 620 |
</div>
|
| 621 |
|
| 622 |
+
<main style={{ position: 'relative', zIndex: 1, flex: 1, overflowY: 'auto' }} className="hide-scrollbar">
|
| 623 |
{children}
|
| 624 |
</main>
|
| 625 |
+
|
| 626 |
+
{/* ⭐ NEW: Realtime Toast Notification Popup */}
|
| 627 |
+
<AnimatePresence>
|
| 628 |
+
{toastNotif && (
|
| 629 |
+
<motion.div
|
| 630 |
+
key={toastNotif.id}
|
| 631 |
+
initial={{ opacity: 0, y: -20, scale: 0.9 }}
|
| 632 |
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
| 633 |
+
exit={{ opacity: 0, y: -20, scale: 0.9 }}
|
| 634 |
+
style={{
|
| 635 |
+
position: 'absolute',
|
| 636 |
+
top: '60px', // Below the header
|
| 637 |
+
right: '2rem', // Aligned near the bell icon
|
| 638 |
+
backgroundColor: '#1e293b',
|
| 639 |
+
border: '1px solid rgba(255,255,255,0.1)',
|
| 640 |
+
borderLeft: '4px solid #FBBF24',
|
| 641 |
+
borderRadius: '12px',
|
| 642 |
+
padding: '1rem 1.5rem',
|
| 643 |
+
display: 'flex',
|
| 644 |
+
alignItems: 'flex-start',
|
| 645 |
+
gap: '1rem',
|
| 646 |
+
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3)',
|
| 647 |
+
zIndex: 9999,
|
| 648 |
+
width: '320px',
|
| 649 |
+
maxWidth: '90vw'
|
| 650 |
+
}}
|
| 651 |
+
>
|
| 652 |
+
<span style={{ fontSize: '1.8rem' }}>{toastNotif.icon}</span>
|
| 653 |
+
<div>
|
| 654 |
+
<h4 style={{ margin: 0, color: 'white', fontWeight: 'bold', fontSize: '1rem' }}>{toastNotif.title}</h4>
|
| 655 |
+
<p style={{ margin: '0.25rem 0 0 0', color: '#cbd5e1', fontSize: '0.9rem' }}>{toastNotif.message}</p>
|
| 656 |
+
</div>
|
| 657 |
+
</motion.div>
|
| 658 |
+
)}
|
| 659 |
+
</AnimatePresence>
|
| 660 |
</div>
|
| 661 |
);
|
| 662 |
}
|
src/components/Icons.jsx
CHANGED
|
@@ -20,6 +20,11 @@ export const ChevronRightIcon = () => (<svg style={{ width: '16px', height: '16p
|
|
| 20 |
export const CheckSquareIcon = () => (<svg style={{ width: '18px', height: '18px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg>);
|
| 21 |
export const MailIcon = () => (<svg style={{ width: '18px', height: '18px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>);
|
| 22 |
export const LoaderIcon = () => (<svg style={{ width: '24px', height: '24px', animation: 'spin 1s linear infinite' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg>);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
// --- Add this to src/components/Icons.js ---
|
| 24 |
|
| 25 |
export const RoundCheckbox = ({ checked, onChange, style }) => (
|
|
|
|
| 20 |
export const CheckSquareIcon = () => (<svg style={{ width: '18px', height: '18px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg>);
|
| 21 |
export const MailIcon = () => (<svg style={{ width: '18px', height: '18px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>);
|
| 22 |
export const LoaderIcon = () => (<svg style={{ width: '24px', height: '24px', animation: 'spin 1s linear infinite' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg>);
|
| 23 |
+
|
| 24 |
+
export const ArrowLeftIcon = () => (<svg style={{ width: '16px', height: '16px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>);
|
| 25 |
+
export const PlusIcon = ({ size = 16 }) => (<svg style={{ width: `${size}px`, height: `${size}px` }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>);
|
| 26 |
+
export const TrashIcon = ({ size = 16 }) => (<svg style={{ width: `${size}px`, height: `${size}px` }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>);
|
| 27 |
+
export const DownloadIcon = () => (<svg style={{ width: '16px', height: '16px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>);
|
| 28 |
// --- Add this to src/components/Icons.js ---
|
| 29 |
|
| 30 |
export const RoundCheckbox = ({ checked, onChange, style }) => (
|
src/components/JobCard.jsx
CHANGED
|
@@ -56,6 +56,16 @@ const JobCard = ({
|
|
| 56 |
isApplying,
|
| 57 |
matchPercentage
|
| 58 |
}) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
return (
|
| 60 |
<div
|
| 61 |
style={{
|
|
@@ -163,7 +173,7 @@ const JobCard = ({
|
|
| 163 |
>
|
| 164 |
{type}
|
| 165 |
</span>
|
| 166 |
-
<span style={{ fontSize: '0.75rem', color: '#9ca3af', fontWeight: '500' }}>
|
| 167 |
Deadline: {deadline}
|
| 168 |
</span>
|
| 169 |
</div>
|
|
@@ -222,19 +232,19 @@ const JobCard = ({
|
|
| 222 |
) : (
|
| 223 |
<button
|
| 224 |
onClick={() => onApply(id)}
|
| 225 |
-
disabled={isApplying}
|
| 226 |
style={{
|
| 227 |
flex: 1,
|
| 228 |
padding: '0.5rem',
|
| 229 |
-
backgroundColor: isApplying ? '#4b5563' : '#FBBF24',
|
| 230 |
border: 'none',
|
| 231 |
-
color: isApplying ? '#d1d5db' : '#1a202c',
|
| 232 |
borderRadius: '0.5rem',
|
| 233 |
fontWeight: 'bold',
|
| 234 |
-
cursor: isApplying ? 'not-allowed' : 'pointer'
|
| 235 |
}}
|
| 236 |
>
|
| 237 |
-
{isApplying ? '...' : 'Apply'}
|
| 238 |
</button>
|
| 239 |
)}
|
| 240 |
</div>
|
|
|
|
| 56 |
isApplying,
|
| 57 |
matchPercentage
|
| 58 |
}) => {
|
| 59 |
+
// Determine if the job is expired based on the deadline string
|
| 60 |
+
const isExpired = React.useMemo(() => {
|
| 61 |
+
if (!deadline || deadline === 'Open') return false;
|
| 62 |
+
const deadlineDate = new Date(deadline);
|
| 63 |
+
// Normalize today to start of day for fair comparison
|
| 64 |
+
const today = new Date();
|
| 65 |
+
today.setHours(0, 0, 0, 0);
|
| 66 |
+
return deadlineDate < today;
|
| 67 |
+
}, [deadline]);
|
| 68 |
+
|
| 69 |
return (
|
| 70 |
<div
|
| 71 |
style={{
|
|
|
|
| 173 |
>
|
| 174 |
{type}
|
| 175 |
</span>
|
| 176 |
+
<span style={{ fontSize: '0.75rem', color: isExpired ? '#EF4444' : '#9ca3af', fontWeight: '500' }}>
|
| 177 |
Deadline: {deadline}
|
| 178 |
</span>
|
| 179 |
</div>
|
|
|
|
| 232 |
) : (
|
| 233 |
<button
|
| 234 |
onClick={() => onApply(id)}
|
| 235 |
+
disabled={isApplying || isExpired}
|
| 236 |
style={{
|
| 237 |
flex: 1,
|
| 238 |
padding: '0.5rem',
|
| 239 |
+
backgroundColor: (isApplying || isExpired) ? '#4b5563' : '#FBBF24',
|
| 240 |
border: 'none',
|
| 241 |
+
color: (isApplying || isExpired) ? '#d1d5db' : '#1a202c',
|
| 242 |
borderRadius: '0.5rem',
|
| 243 |
fontWeight: 'bold',
|
| 244 |
+
cursor: (isApplying || isExpired) ? 'not-allowed' : 'pointer'
|
| 245 |
}}
|
| 246 |
>
|
| 247 |
+
{isApplying ? '...' : (isExpired ? 'Expired' : 'Apply')}
|
| 248 |
</button>
|
| 249 |
)}
|
| 250 |
</div>
|
src/components/JobDetail.jsx
CHANGED
|
@@ -10,6 +10,15 @@ const CloseIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" width="24" heig
|
|
| 10 |
const CheckIcon = () => (<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>);
|
| 11 |
|
| 12 |
const JobDetail = ({ job, onClose, onApply, isApplied, isApplying }) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
// Portal renders this outside the main app flow
|
| 14 |
return ReactDOM.createPortal(
|
| 15 |
<AnimatePresence>
|
|
@@ -74,7 +83,7 @@ const JobDetail = ({ job, onClose, onApply, isApplied, isApplying }) => {
|
|
| 74 |
)}
|
| 75 |
|
| 76 |
<div style={{ fontSize: '0.9rem', color: '#6b7280', marginTop: '1rem', borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '1rem' }}>
|
| 77 |
-
Posted: {job.postedAt} • Deadline: <span style={{ color: '#ef4444' }}>{job.deadline}</span>
|
| 78 |
</div>
|
| 79 |
</div>
|
| 80 |
|
|
@@ -90,15 +99,15 @@ const JobDetail = ({ job, onClose, onApply, isApplied, isApplying }) => {
|
|
| 90 |
) : (
|
| 91 |
<button
|
| 92 |
onClick={() => onApply && onApply(job.id)}
|
| 93 |
-
disabled={isApplying}
|
| 94 |
style={{
|
| 95 |
padding: '0.75rem 2rem',
|
| 96 |
-
backgroundColor: isApplying ? '#4b5563' : '#FBBF24',
|
| 97 |
-
color: isApplying ? '#d1d5db' : '#1a202c',
|
| 98 |
-
border: 'none', borderRadius: '0.5rem', fontWeight: 'bold', cursor: isApplying ? 'not-allowed' : 'pointer'
|
| 99 |
}}
|
| 100 |
>
|
| 101 |
-
{isApplying ? 'Applying...' : 'Apply Now'}
|
| 102 |
</button>
|
| 103 |
)}
|
| 104 |
</div>
|
|
|
|
| 10 |
const CheckIcon = () => (<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>);
|
| 11 |
|
| 12 |
const JobDetail = ({ job, onClose, onApply, isApplied, isApplying }) => {
|
| 13 |
+
// Determine if the job is expired based on the deadline string
|
| 14 |
+
const isExpired = React.useMemo(() => {
|
| 15 |
+
if (!job || !job.deadline || job.deadline === 'Open') return false;
|
| 16 |
+
const deadlineDate = new Date(job.deadline);
|
| 17 |
+
const today = new Date();
|
| 18 |
+
today.setHours(0, 0, 0, 0);
|
| 19 |
+
return deadlineDate < today;
|
| 20 |
+
}, [job]);
|
| 21 |
+
|
| 22 |
// Portal renders this outside the main app flow
|
| 23 |
return ReactDOM.createPortal(
|
| 24 |
<AnimatePresence>
|
|
|
|
| 83 |
)}
|
| 84 |
|
| 85 |
<div style={{ fontSize: '0.9rem', color: '#6b7280', marginTop: '1rem', borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '1rem' }}>
|
| 86 |
+
Posted: {job.postedAt} • Deadline: <span style={{ color: isExpired ? '#ef4444' : '#d1d5db' }}>{job.deadline}</span>
|
| 87 |
</div>
|
| 88 |
</div>
|
| 89 |
|
|
|
|
| 99 |
) : (
|
| 100 |
<button
|
| 101 |
onClick={() => onApply && onApply(job.id)}
|
| 102 |
+
disabled={isApplying || isExpired}
|
| 103 |
style={{
|
| 104 |
padding: '0.75rem 2rem',
|
| 105 |
+
backgroundColor: (isApplying || isExpired) ? '#4b5563' : '#FBBF24',
|
| 106 |
+
color: (isApplying || isExpired) ? '#d1d5db' : '#1a202c',
|
| 107 |
+
border: 'none', borderRadius: '0.5rem', fontWeight: 'bold', cursor: (isApplying || isExpired) ? 'not-allowed' : 'pointer'
|
| 108 |
}}
|
| 109 |
>
|
| 110 |
+
{isApplying ? 'Applying...' : (isExpired ? 'Expired' : 'Apply Now')}
|
| 111 |
</button>
|
| 112 |
)}
|
| 113 |
</div>
|
src/components/ResumeBuilder.jsx
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { DownloadIcon, ArrowLeftIcon, PlusIcon, TrashIcon } from './Icons';
|
| 4 |
+
|
| 5 |
+
export default function ResumeBuilder({ analysisResult, onBack }) {
|
| 6 |
+
const printRef = useRef(null);
|
| 7 |
+
const missingKeywords = analysisResult?.matches?.filter(m => !m.found) || [];
|
| 8 |
+
|
| 9 |
+
const resumeData = analysisResult?.resume_data || {};
|
| 10 |
+
|
| 11 |
+
// Form State - Auto-fill from resume_data if available
|
| 12 |
+
const [personalInfo, setPersonalInfo] = useState({
|
| 13 |
+
fullName: resumeData.name || '',
|
| 14 |
+
email: resumeData.email || '',
|
| 15 |
+
phone: resumeData.phone || '',
|
| 16 |
+
location: resumeData.location || '',
|
| 17 |
+
linkedin: resumeData.linkedin || '',
|
| 18 |
+
portfolio: resumeData.portfolio || ''
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
const [summary, setSummary] = useState(resumeData.summary || '');
|
| 22 |
+
|
| 23 |
+
const [experience, setExperience] = useState(
|
| 24 |
+
resumeData.work_experience?.length > 0
|
| 25 |
+
? resumeData.work_experience.map((exp, idx) => ({
|
| 26 |
+
id: Date.now() + idx,
|
| 27 |
+
company: exp.company || '',
|
| 28 |
+
role: exp.role || '',
|
| 29 |
+
startDate: exp.start_date || '',
|
| 30 |
+
endDate: exp.end_date || '',
|
| 31 |
+
description: Array.isArray(exp.responsibilities) ? exp.responsibilities.map(r => `• ${r}`).join('\n') : (exp.responsibilities || '')
|
| 32 |
+
}))
|
| 33 |
+
: [{ id: 1, company: '', role: '', startDate: '', endDate: '', description: '' }]
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
const [education, setEducation] = useState(
|
| 37 |
+
resumeData.education?.length > 0
|
| 38 |
+
? resumeData.education.map((edu, idx) => ({
|
| 39 |
+
id: Date.now() + idx,
|
| 40 |
+
institution: edu.institution || '',
|
| 41 |
+
degree: edu.degree || '',
|
| 42 |
+
startDate: edu.start_date || '',
|
| 43 |
+
endDate: edu.end_date || ''
|
| 44 |
+
}))
|
| 45 |
+
: [{ id: 1, institution: '', degree: '', startDate: '', endDate: '' }]
|
| 46 |
+
);
|
| 47 |
+
|
| 48 |
+
const initialSkills = [];
|
| 49 |
+
if (resumeData.technical_skills) initialSkills.push(...resumeData.technical_skills);
|
| 50 |
+
if (resumeData.skills) initialSkills.push(...resumeData.skills);
|
| 51 |
+
|
| 52 |
+
const [skills, setSkills] = useState(initialSkills.join(', '));
|
| 53 |
+
|
| 54 |
+
// Drag and Drop Handlers
|
| 55 |
+
const handleDragStart = (e, keyword) => {
|
| 56 |
+
e.dataTransfer.setData("text/plain", keyword);
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const handleDrop = (e, setter, currentValue) => {
|
| 60 |
+
e.preventDefault();
|
| 61 |
+
const draggedText = e.dataTransfer.getData("text/plain");
|
| 62 |
+
if (draggedText) {
|
| 63 |
+
// Append the dropped text
|
| 64 |
+
// Determine if we need a comma or space based on context (hacky but functional for this scope)
|
| 65 |
+
const separator = currentValue ? (setter === setSkills ? ', ' : ' ') : '';
|
| 66 |
+
setter(`${currentValue}${separator}${draggedText}`);
|
| 67 |
+
}
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
const handleExperienceDrop = (e, id, currentValue) => {
|
| 71 |
+
e.preventDefault();
|
| 72 |
+
const draggedText = e.dataTransfer.getData("text/plain");
|
| 73 |
+
if (draggedText) {
|
| 74 |
+
const separator = currentValue ? ' ' : '';
|
| 75 |
+
updateExperience(id, 'description', `${currentValue}${separator}${draggedText}`);
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
const handleDragOver = (e) => {
|
| 80 |
+
e.preventDefault(); // Necessary to allow dropping
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
// Handlers
|
| 84 |
+
const handlePersonalInfoChange = (e) => {
|
| 85 |
+
const { name, value } = e.target;
|
| 86 |
+
setPersonalInfo(prev => ({ ...prev, [name]: value }));
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
const addExperience = () => {
|
| 90 |
+
setExperience(prev => [...prev, { id: Date.now(), company: '', role: '', startDate: '', endDate: '', description: '' }]);
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
const updateExperience = (id, field, value) => {
|
| 94 |
+
setExperience(prev => prev.map(exp => exp.id === id ? { ...exp, [field]: value } : exp));
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
const removeExperience = (id) => {
|
| 98 |
+
setExperience(prev => prev.filter(exp => exp.id !== id));
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
const addEducation = () => {
|
| 102 |
+
setEducation(prev => [...prev, { id: Date.now(), institution: '', degree: '', startDate: '', endDate: '' }]);
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
const updateEducation = (id, field, value) => {
|
| 106 |
+
setEducation(prev => prev.map(edu => edu.id === id ? { ...edu, [field]: value } : edu));
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
const removeEducation = (id) => {
|
| 110 |
+
setEducation(prev => prev.filter(edu => edu.id !== id));
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
const handlePrint = () => {
|
| 114 |
+
window.print();
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
const inputStyle = {
|
| 118 |
+
width: '100%',
|
| 119 |
+
padding: '0.75rem',
|
| 120 |
+
borderRadius: '0.5rem',
|
| 121 |
+
border: '1px solid rgba(236, 183, 26, 0.46)',
|
| 122 |
+
backgroundColor: 'rgba(255,255,255,0.05)',
|
| 123 |
+
color: 'white',
|
| 124 |
+
marginBottom: '1rem',
|
| 125 |
+
fontFamily: 'inherit',
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const labelStyle = {
|
| 129 |
+
display: 'block',
|
| 130 |
+
marginBottom: '0.5rem',
|
| 131 |
+
color: '#d1d5db',
|
| 132 |
+
fontSize: '0.875rem'
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
const sectionHeaderStyle = {
|
| 136 |
+
fontSize: '1.25rem',
|
| 137 |
+
fontWeight: 'bold',
|
| 138 |
+
color: '#FBBF24',
|
| 139 |
+
marginBottom: '1rem',
|
| 140 |
+
borderBottom: '1px solid rgba(236, 183, 26, 0.3)',
|
| 141 |
+
paddingBottom: '0.5rem'
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
return (
|
| 145 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(300px, 1fr) 1.5fr', gap: '2rem', height: 'calc(100vh - 150px)' }}>
|
| 146 |
+
|
| 147 |
+
{/* Left Panel: Form & Gaps */}
|
| 148 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', overflowY: 'auto', paddingRight: '1rem' }} className="no-print hide-scrollbar">
|
| 149 |
+
|
| 150 |
+
{/* Header Actions */}
|
| 151 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 152 |
+
<button
|
| 153 |
+
onClick={onBack}
|
| 154 |
+
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', background: 'none', border: 'none', color: '#d1d5db', cursor: 'pointer', padding: '0.5rem 0' }}
|
| 155 |
+
>
|
| 156 |
+
<ArrowLeftIcon /> Back to Analysis
|
| 157 |
+
</button>
|
| 158 |
+
<button
|
| 159 |
+
onClick={handlePrint}
|
| 160 |
+
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', backgroundColor: '#FBBF24', color: 'black', border: 'none', padding: '0.5rem 1rem', borderRadius: '0.5rem', fontWeight: 'bold', cursor: 'pointer' }}
|
| 161 |
+
>
|
| 162 |
+
<DownloadIcon /> Export PDF
|
| 163 |
+
</button>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
{/* Fix Your Gaps Panel */}
|
| 167 |
+
{missingKeywords.length > 0 && (
|
| 168 |
+
<div style={{
|
| 169 |
+
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
| 170 |
+
border: '1px solid rgba(239, 68, 68, 0.3)',
|
| 171 |
+
borderRadius: '1rem',
|
| 172 |
+
padding: '1.5rem',
|
| 173 |
+
position: 'sticky',
|
| 174 |
+
top: 0,
|
| 175 |
+
zIndex: 10,
|
| 176 |
+
backdropFilter: 'blur(8px)', // Optional: makes it look better when scrolling over text
|
| 177 |
+
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.5)'
|
| 178 |
+
}}>
|
| 179 |
+
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', color: '#EF4444', marginBottom: '1rem' }}>Fix Your ATS Gaps</h3>
|
| 180 |
+
<p style={{ color: '#d1d5db', fontSize: '0.875rem', marginBottom: '1rem' }}>Drag and drop these keywords into your text fields below.</p>
|
| 181 |
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
| 182 |
+
{missingKeywords.map((match, idx) => (
|
| 183 |
+
<span
|
| 184 |
+
key={idx}
|
| 185 |
+
draggable
|
| 186 |
+
onDragStart={(e) => handleDragStart(e, match.keyword)}
|
| 187 |
+
style={{
|
| 188 |
+
backgroundColor: 'rgba(239, 68, 68, 0.2)',
|
| 189 |
+
color: '#FCA5A5',
|
| 190 |
+
padding: '0.25rem 0.5rem',
|
| 191 |
+
borderRadius: '0.25rem',
|
| 192 |
+
fontSize: '0.75rem',
|
| 193 |
+
border: '1px solid rgba(239, 68, 68, 0.4)',
|
| 194 |
+
cursor: 'grab'
|
| 195 |
+
}}
|
| 196 |
+
>
|
| 197 |
+
{match.keyword}
|
| 198 |
+
</span>
|
| 199 |
+
))}
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
)}
|
| 203 |
+
|
| 204 |
+
{/* Builder Form */}
|
| 205 |
+
<div style={{ backgroundColor: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '1rem', padding: '1.5rem' }}>
|
| 206 |
+
|
| 207 |
+
{/* Personal Info */}
|
| 208 |
+
<div style={{ marginBottom: '2rem' }}>
|
| 209 |
+
<h3 style={sectionHeaderStyle}>Personal Information</h3>
|
| 210 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
| 211 |
+
<div><label style={labelStyle}>Full Name</label><input style={inputStyle} name="fullName" value={personalInfo.fullName} onChange={handlePersonalInfoChange} placeholder="John Doe" /></div>
|
| 212 |
+
<div><label style={labelStyle}>Email</label><input style={inputStyle} name="email" value={personalInfo.email} onChange={handlePersonalInfoChange} placeholder="john@example.com" /></div>
|
| 213 |
+
<div><label style={labelStyle}>Phone</label><input style={inputStyle} name="phone" value={personalInfo.phone} onChange={handlePersonalInfoChange} placeholder="(555) 123-4567" /></div>
|
| 214 |
+
<div><label style={labelStyle}>Location</label><input style={inputStyle} name="location" value={personalInfo.location} onChange={handlePersonalInfoChange} placeholder="City, State" /></div>
|
| 215 |
+
<div><label style={labelStyle}>LinkedIn (Optional)</label><input style={inputStyle} name="linkedin" value={personalInfo.linkedin} onChange={handlePersonalInfoChange} placeholder="linkedin.com/in/johndoe" /></div>
|
| 216 |
+
<div><label style={labelStyle}>Portfolio (Optional)</label><input style={inputStyle} name="portfolio" value={personalInfo.portfolio} onChange={handlePersonalInfoChange} placeholder="johndoe.com" /></div>
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
|
| 220 |
+
{/* Summary */}
|
| 221 |
+
<div style={{ marginBottom: '2rem' }}>
|
| 222 |
+
<h3 style={sectionHeaderStyle}>Professional Summary</h3>
|
| 223 |
+
<textarea
|
| 224 |
+
style={{ ...inputStyle, minHeight: '100px', resize: 'vertical' }}
|
| 225 |
+
value={summary}
|
| 226 |
+
onChange={e => setSummary(e.target.value)}
|
| 227 |
+
onDrop={(e) => handleDrop(e, setSummary, summary)}
|
| 228 |
+
onDragOver={handleDragOver}
|
| 229 |
+
placeholder="Brief overview of your professional background and goals..."
|
| 230 |
+
/>
|
| 231 |
+
</div>
|
| 232 |
+
|
| 233 |
+
{/* Experience */}
|
| 234 |
+
<div style={{ marginBottom: '2rem' }}>
|
| 235 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
| 236 |
+
<h3 style={{ ...sectionHeaderStyle, borderBottom: 'none', marginBottom: 0 }}>Experience</h3>
|
| 237 |
+
<button onClick={addExperience} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', background: 'none', border: '1px solid #FBBF24', color: '#FBBF24', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', cursor: 'pointer', fontSize: '0.75rem' }}>
|
| 238 |
+
<PlusIcon size={14} /> Add Role
|
| 239 |
+
</button>
|
| 240 |
+
</div>
|
| 241 |
+
{experience.map((exp, index) => (
|
| 242 |
+
<div key={exp.id} style={{ backgroundColor: 'rgba(0,0,0,0.2)', padding: '1rem', borderRadius: '0.5rem', marginBottom: '1rem', position: 'relative' }}>
|
| 243 |
+
{index > 0 && (
|
| 244 |
+
<button onClick={() => removeExperience(exp.id)} style={{ position: 'absolute', top: '1rem', right: '1rem', background: 'none', border: 'none', color: '#EF4444', cursor: 'pointer' }} title="Remove Role">
|
| 245 |
+
<TrashIcon size={16} />
|
| 246 |
+
</button>
|
| 247 |
+
)}
|
| 248 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
| 249 |
+
<div><label style={labelStyle}>Company</label><input style={inputStyle} value={exp.company} onChange={e => updateExperience(exp.id, 'company', e.target.value)} placeholder="Acme Corp" /></div>
|
| 250 |
+
<div><label style={labelStyle}>Job Title</label><input style={inputStyle} value={exp.role} onChange={e => updateExperience(exp.id, 'role', e.target.value)} placeholder="Software Engineer" /></div>
|
| 251 |
+
<div><label style={labelStyle}>Start Date</label><input style={inputStyle} value={exp.startDate} onChange={e => updateExperience(exp.id, 'startDate', e.target.value)} placeholder="MMM YYYY" /></div>
|
| 252 |
+
<div><label style={labelStyle}>End Date</label><input style={inputStyle} value={exp.endDate} onChange={e => updateExperience(exp.id, 'endDate', e.target.value)} placeholder="MMM YYYY or Present" /></div>
|
| 253 |
+
</div>
|
| 254 |
+
<label style={labelStyle}>Description (Bullet points recommended)</label>
|
| 255 |
+
<textarea
|
| 256 |
+
style={{ ...inputStyle, minHeight: '80px', resize: 'vertical', marginBottom: 0 }}
|
| 257 |
+
value={exp.description}
|
| 258 |
+
onChange={e => updateExperience(exp.id, 'description', e.target.value)}
|
| 259 |
+
onDrop={(e) => handleExperienceDrop(e, exp.id, exp.description)}
|
| 260 |
+
onDragOver={handleDragOver}
|
| 261 |
+
placeholder="• Achieved X by doing Y..."
|
| 262 |
+
/>
|
| 263 |
+
</div>
|
| 264 |
+
))}
|
| 265 |
+
</div>
|
| 266 |
+
|
| 267 |
+
{/* Education */}
|
| 268 |
+
<div style={{ marginBottom: '2rem' }}>
|
| 269 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
| 270 |
+
<h3 style={{ ...sectionHeaderStyle, borderBottom: 'none', marginBottom: 0 }}>Education</h3>
|
| 271 |
+
<button onClick={addEducation} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', background: 'none', border: '1px solid #FBBF24', color: '#FBBF24', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', cursor: 'pointer', fontSize: '0.75rem' }}>
|
| 272 |
+
<PlusIcon size={14} /> Add Degree
|
| 273 |
+
</button>
|
| 274 |
+
</div>
|
| 275 |
+
{education.map((edu, index) => (
|
| 276 |
+
<div key={edu.id} style={{ backgroundColor: 'rgba(0,0,0,0.2)', padding: '1rem', borderRadius: '0.5rem', marginBottom: '1rem', position: 'relative' }}>
|
| 277 |
+
{index > 0 && (
|
| 278 |
+
<button onClick={() => removeEducation(edu.id)} style={{ position: 'absolute', top: '1rem', right: '1rem', background: 'none', border: 'none', color: '#EF4444', cursor: 'pointer' }} title="Remove Degree">
|
| 279 |
+
<TrashIcon size={16} />
|
| 280 |
+
</button>
|
| 281 |
+
)}
|
| 282 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
| 283 |
+
<div><label style={labelStyle}>Institution</label><input style={{ ...inputStyle, marginBottom: 0 }} value={edu.institution} onChange={e => updateEducation(edu.id, 'institution', e.target.value)} placeholder="University of State" /></div>
|
| 284 |
+
<div><label style={labelStyle}>Degree / Major</label><input style={{ ...inputStyle, marginBottom: 0 }} value={edu.degree} onChange={e => updateEducation(edu.id, 'degree', e.target.value)} placeholder="B.S. Computer Science" /></div>
|
| 285 |
+
<div><label style={labelStyle}>Start Date</label><input style={{ ...inputStyle, marginBottom: 0 }} value={edu.startDate} onChange={e => updateEducation(edu.id, 'startDate', e.target.value)} placeholder="YYYY" /></div>
|
| 286 |
+
<div><label style={labelStyle}>End Date</label><input style={{ ...inputStyle, marginBottom: 0 }} value={edu.endDate} onChange={e => updateEducation(edu.id, 'endDate', e.target.value)} placeholder="YYYY" /></div>
|
| 287 |
+
</div>
|
| 288 |
+
</div>
|
| 289 |
+
))}
|
| 290 |
+
</div>
|
| 291 |
+
|
| 292 |
+
{/* Skills */}
|
| 293 |
+
<div>
|
| 294 |
+
<h3 style={sectionHeaderStyle}>Skills</h3>
|
| 295 |
+
<label style={labelStyle}>Comma separated list of skills</label>
|
| 296 |
+
<textarea
|
| 297 |
+
style={{ ...inputStyle, minHeight: '80px', resize: 'vertical' }}
|
| 298 |
+
value={skills}
|
| 299 |
+
onChange={e => setSkills(e.target.value)}
|
| 300 |
+
onDrop={(e) => handleDrop(e, setSkills, skills)}
|
| 301 |
+
onDragOver={handleDragOver}
|
| 302 |
+
placeholder="React, Python, SQL, Project Management..."
|
| 303 |
+
/>
|
| 304 |
+
</div>
|
| 305 |
+
|
| 306 |
+
</div>
|
| 307 |
+
</div>
|
| 308 |
+
|
| 309 |
+
{/* Right Panel: Live Preview (Printable Area) */}
|
| 310 |
+
<div
|
| 311 |
+
style={{
|
| 312 |
+
backgroundColor: '#52525B', // Darker backdrop for contrast
|
| 313 |
+
padding: '2rem',
|
| 314 |
+
overflowY: 'auto',
|
| 315 |
+
borderRadius: '1rem',
|
| 316 |
+
display: 'flex',
|
| 317 |
+
justifyContent: 'center'
|
| 318 |
+
}}
|
| 319 |
+
className="print-container no-print hide-scrollbar" // Wrapper won't print, only content below
|
| 320 |
+
>
|
| 321 |
+
{/* Actual Paper Element */}
|
| 322 |
+
<div
|
| 323 |
+
ref={printRef}
|
| 324 |
+
className="resume-print-area"
|
| 325 |
+
style={{
|
| 326 |
+
backgroundColor: 'white',
|
| 327 |
+
color: '#333333',
|
| 328 |
+
width: '100%',
|
| 329 |
+
maxWidth: '800px',
|
| 330 |
+
minHeight: '1056px', // ~8.5x11 aspect ratio
|
| 331 |
+
padding: '40px 50px',
|
| 332 |
+
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.5)',
|
| 333 |
+
fontFamily: "'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif",
|
| 334 |
+
lineHeight: '1.5',
|
| 335 |
+
boxSizing: 'border-box'
|
| 336 |
+
}}
|
| 337 |
+
>
|
| 338 |
+
{/* Header */}
|
| 339 |
+
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
|
| 340 |
+
<h1 style={{ fontSize: '36px', fontWeight: '800', margin: '0 0 5px 0', color: '#1e3a8a', letterSpacing: '-0.5px' }}>
|
| 341 |
+
{personalInfo.fullName || 'Your Name'}
|
| 342 |
+
</h1>
|
| 343 |
+
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '10px', fontSize: '14px', color: '#555555' }}>
|
| 344 |
+
{personalInfo.location && <span>{personalInfo.location}</span>}
|
| 345 |
+
{personalInfo.location && (personalInfo.phone || personalInfo.email) && <span style={{ color: '#ccc' }}>•</span>}
|
| 346 |
+
{personalInfo.phone && <span>{personalInfo.phone}</span>}
|
| 347 |
+
{personalInfo.phone && personalInfo.email && <span style={{ color: '#ccc' }}>•</span>}
|
| 348 |
+
{personalInfo.email && <span style={{ color: '#1e3a8a' }}>{personalInfo.email}</span>}
|
| 349 |
+
{personalInfo.linkedin && <><span style={{ color: '#ccc' }}>•</span><span style={{ color: '#1e3a8a' }}>{personalInfo.linkedin}</span></>}
|
| 350 |
+
{personalInfo.portfolio && <><span style={{ color: '#ccc' }}>•</span><span style={{ color: '#1e3a8a' }}>{personalInfo.portfolio}</span></>}
|
| 351 |
+
</div>
|
| 352 |
+
</div>
|
| 353 |
+
|
| 354 |
+
{/* Summary Preview */}
|
| 355 |
+
{summary && (
|
| 356 |
+
<div style={{ marginBottom: '20px' }}>
|
| 357 |
+
<p style={{ fontSize: '15px', color: '#444444', margin: 0, textAlign: 'justify' }}>
|
| 358 |
+
{summary}
|
| 359 |
+
</p>
|
| 360 |
+
</div>
|
| 361 |
+
)}
|
| 362 |
+
|
| 363 |
+
{/* Skills Preview */}
|
| 364 |
+
{skills && (
|
| 365 |
+
<div style={{ marginBottom: '20px' }}>
|
| 366 |
+
<h2 style={{ fontSize: '16px', fontWeight: '700', color: '#1e3a8a', textTransform: 'uppercase', borderBottom: '2px solid #1e3a8a', paddingBottom: '4px', marginBottom: '12px', letterSpacing: '0.5px' }}>
|
| 367 |
+
Technical Skills
|
| 368 |
+
</h2>
|
| 369 |
+
<p style={{ fontSize: '15px', color: '#333333', margin: 0, lineHeight: '1.6' }}>
|
| 370 |
+
{skills}
|
| 371 |
+
</p>
|
| 372 |
+
</div>
|
| 373 |
+
)}
|
| 374 |
+
|
| 375 |
+
{/* Experience Preview */}
|
| 376 |
+
{experience.some(exp => exp.company || exp.role) && (
|
| 377 |
+
<div style={{ marginBottom: '20px' }}>
|
| 378 |
+
<h2 style={{ fontSize: '16px', fontWeight: '700', color: '#1e3a8a', textTransform: 'uppercase', borderBottom: '2px solid #1e3a8a', paddingBottom: '4px', marginBottom: '12px', letterSpacing: '0.5px' }}>
|
| 379 |
+
Professional Experience
|
| 380 |
+
</h2>
|
| 381 |
+
{experience.map(exp => (
|
| 382 |
+
(exp.company || exp.role) ? (
|
| 383 |
+
<div key={exp.id} style={{ marginBottom: '16px' }}>
|
| 384 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2px' }}>
|
| 385 |
+
<div style={{ fontWeight: '700', fontSize: '16px', color: '#222222' }}>{exp.role || 'Role'}</div>
|
| 386 |
+
<div style={{ fontSize: '14px', color: '#666666', fontStyle: 'italic', whiteSpace: 'nowrap' }}>
|
| 387 |
+
{exp.startDate} {exp.startDate && exp.endDate && '–'} {exp.endDate}
|
| 388 |
+
</div>
|
| 389 |
+
</div>
|
| 390 |
+
<div style={{ fontSize: '15px', color: '#1e3a8a', fontWeight: '600', marginBottom: '8px' }}>{exp.company}</div>
|
| 391 |
+
{exp.description && (
|
| 392 |
+
<div style={{ fontSize: '14px', color: '#444444' }}>
|
| 393 |
+
{exp.description.split('\n').map((line, i) => (
|
| 394 |
+
<div style={{ display: 'flex', marginBottom: '4px' }} key={i}>
|
| 395 |
+
{line.trim().startsWith('•') || line.trim().startsWith('-') ? (
|
| 396 |
+
<span style={{ marginRight: '8px', color: '#1e3a8a' }}>•</span>
|
| 397 |
+
) : line.trim() ? (
|
| 398 |
+
<span style={{ marginRight: '8px', color: '#1e3a8a' }}>•</span>
|
| 399 |
+
) : null}
|
| 400 |
+
<span style={{ flex: 1 }}>{line.replace(/^[•-]\s*/, '')}</span>
|
| 401 |
+
</div>
|
| 402 |
+
))}
|
| 403 |
+
</div>
|
| 404 |
+
)}
|
| 405 |
+
</div>
|
| 406 |
+
) : null
|
| 407 |
+
))}
|
| 408 |
+
</div>
|
| 409 |
+
)}
|
| 410 |
+
|
| 411 |
+
{/* Education Preview */}
|
| 412 |
+
{education.some(edu => edu.institution || edu.degree) && (
|
| 413 |
+
<div style={{ marginBottom: '20px' }}>
|
| 414 |
+
<h2 style={{ fontSize: '16px', fontWeight: '700', color: '#1e3a8a', textTransform: 'uppercase', borderBottom: '2px solid #1e3a8a', paddingBottom: '4px', marginBottom: '12px', letterSpacing: '0.5px' }}>
|
| 415 |
+
Education
|
| 416 |
+
</h2>
|
| 417 |
+
{education.map(edu => (
|
| 418 |
+
(edu.institution || edu.degree) ? (
|
| 419 |
+
<div key={edu.id} style={{ marginBottom: '12px' }}>
|
| 420 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2px' }}>
|
| 421 |
+
<div style={{ fontWeight: '700', fontSize: '15px', color: '#222222' }}>{edu.institution || 'Institution'}</div>
|
| 422 |
+
<div style={{ fontSize: '14px', color: '#666666', fontStyle: 'italic', whiteSpace: 'nowrap' }}>
|
| 423 |
+
{edu.startDate} {edu.startDate && edu.endDate && '–'} {edu.endDate}
|
| 424 |
+
</div>
|
| 425 |
+
</div>
|
| 426 |
+
<div style={{ fontSize: '15px', color: '#444444' }}>{edu.degree}</div>
|
| 427 |
+
</div>
|
| 428 |
+
) : null
|
| 429 |
+
))}
|
| 430 |
+
</div>
|
| 431 |
+
)}
|
| 432 |
+
</div>
|
| 433 |
+
</div>
|
| 434 |
+
|
| 435 |
+
</div>
|
| 436 |
+
);
|
| 437 |
+
}
|
src/index.css
CHANGED
|
@@ -25,4 +25,54 @@ body {
|
|
| 25 |
.dark-select option {
|
| 26 |
background-color: #111827;
|
| 27 |
color: rgb(4, 42, 31);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
|
|
|
| 25 |
.dark-select option {
|
| 26 |
background-color: #111827;
|
| 27 |
color: rgb(4, 42, 31);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* ===============================
|
| 31 |
+
Scrollbar Utilities
|
| 32 |
+
=============================== */
|
| 33 |
+
.hide-scrollbar::-webkit-scrollbar {
|
| 34 |
+
display: none;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.hide-scrollbar {
|
| 38 |
+
-ms-overflow-style: none; /* IE and Edge */
|
| 39 |
+
scrollbar-width: none; /* Firefox */
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/* ===============================
|
| 43 |
+
Print Styles for Resume Builder
|
| 44 |
+
=============================== */
|
| 45 |
+
@media print {
|
| 46 |
+
body * {
|
| 47 |
+
visibility: hidden;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.print-container, .print-container * {
|
| 51 |
+
visibility: visible;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.print-container {
|
| 55 |
+
position: absolute;
|
| 56 |
+
left: 0;
|
| 57 |
+
top: 0;
|
| 58 |
+
width: 100vw;
|
| 59 |
+
margin: 0;
|
| 60 |
+
padding: 0;
|
| 61 |
+
background: white !important;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.no-print {
|
| 65 |
+
display: none !important;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.resume-print-area {
|
| 69 |
+
width: 100% !important;
|
| 70 |
+
max-width: none !important;
|
| 71 |
+
padding: 0 !important;
|
| 72 |
+
box-shadow: none !important;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
@page {
|
| 76 |
+
margin: 1cm;
|
| 77 |
+
}
|
| 78 |
}
|
src/pages/ApplicantATS.jsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import React, { useRef, useState } from 'react';
|
| 2 |
import ApplicantLayout from '../components/ApplicantLayout';
|
|
|
|
| 3 |
import { motion } from 'framer-motion';
|
| 4 |
import {
|
| 5 |
UploadIcon,
|
|
@@ -13,6 +14,7 @@ export default function ApplicantATS({ onNavigate }) {
|
|
| 13 |
const [jobDescription, setJobDescription] = useState('');
|
| 14 |
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
| 15 |
const [analysisResult, setAnalysisResult] = useState(null);
|
|
|
|
| 16 |
|
| 17 |
// -------------------------
|
| 18 |
// Handle Resume Upload
|
|
@@ -82,6 +84,14 @@ export default function ApplicantATS({ onNavigate }) {
|
|
| 82 |
|
| 83 |
const isDisabled = !resumeFile || !jobDescription.trim() || isAnalyzing;
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
return (
|
| 86 |
<ApplicantLayout activePage="applicant-ats" onNavigate={onNavigate}>
|
| 87 |
<div>
|
|
@@ -142,7 +152,24 @@ export default function ApplicantATS({ onNavigate }) {
|
|
| 142 |
{analysisResult.score}/100
|
| 143 |
</div>
|
| 144 |
<p style={{ fontSize: '1.25rem', color: '#d1d5db' }}>ATS Match Score</p>
|
| 145 |
-
<p style={{ color: '#d1d5db', marginTop: '0.5rem' }}>{analysisResult.summary}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
</div>
|
| 147 |
|
| 148 |
{/* Keyword Matches */}
|
|
@@ -257,6 +284,7 @@ export default function ApplicantATS({ onNavigate }) {
|
|
| 257 |
resize: 'vertical',
|
| 258 |
fontFamily: 'inherit',
|
| 259 |
}}
|
|
|
|
| 260 |
/>
|
| 261 |
</div>
|
| 262 |
|
|
|
|
| 1 |
import React, { useRef, useState } from 'react';
|
| 2 |
import ApplicantLayout from '../components/ApplicantLayout';
|
| 3 |
+
import ResumeBuilder from '../components/ResumeBuilder';
|
| 4 |
import { motion } from 'framer-motion';
|
| 5 |
import {
|
| 6 |
UploadIcon,
|
|
|
|
| 14 |
const [jobDescription, setJobDescription] = useState('');
|
| 15 |
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
| 16 |
const [analysisResult, setAnalysisResult] = useState(null);
|
| 17 |
+
const [showBuilder, setShowBuilder] = useState(false);
|
| 18 |
|
| 19 |
// -------------------------
|
| 20 |
// Handle Resume Upload
|
|
|
|
| 84 |
|
| 85 |
const isDisabled = !resumeFile || !jobDescription.trim() || isAnalyzing;
|
| 86 |
|
| 87 |
+
if (showBuilder) {
|
| 88 |
+
return (
|
| 89 |
+
<ApplicantLayout activePage="applicant-ats" onNavigate={onNavigate}>
|
| 90 |
+
<ResumeBuilder analysisResult={analysisResult} onBack={() => setShowBuilder(false)} />
|
| 91 |
+
</ApplicantLayout>
|
| 92 |
+
);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
return (
|
| 96 |
<ApplicantLayout activePage="applicant-ats" onNavigate={onNavigate}>
|
| 97 |
<div>
|
|
|
|
| 152 |
{analysisResult.score}/100
|
| 153 |
</div>
|
| 154 |
<p style={{ fontSize: '1.25rem', color: '#d1d5db' }}>ATS Match Score</p>
|
| 155 |
+
<p style={{ color: '#d1d5db', marginTop: '0.5rem', marginBottom: '1.5rem' }}>{analysisResult.summary}</p>
|
| 156 |
+
|
| 157 |
+
<button
|
| 158 |
+
onClick={() => setShowBuilder(true)}
|
| 159 |
+
style={{
|
| 160 |
+
backgroundColor: '#FBBF24',
|
| 161 |
+
color: '#111827',
|
| 162 |
+
padding: '0.75rem 1.5rem',
|
| 163 |
+
borderRadius: '0.5rem',
|
| 164 |
+
fontWeight: 'bold',
|
| 165 |
+
border: 'none',
|
| 166 |
+
cursor: 'pointer',
|
| 167 |
+
fontSize: '1rem',
|
| 168 |
+
boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
|
| 169 |
+
}}
|
| 170 |
+
>
|
| 171 |
+
Create Optimized Resume
|
| 172 |
+
</button>
|
| 173 |
</div>
|
| 174 |
|
| 175 |
{/* Keyword Matches */}
|
|
|
|
| 284 |
resize: 'vertical',
|
| 285 |
fontFamily: 'inherit',
|
| 286 |
}}
|
| 287 |
+
className="hide-scrollbar"
|
| 288 |
/>
|
| 289 |
</div>
|
| 290 |
|
src/pages/ApplicantJobPage.jsx
CHANGED
|
@@ -157,12 +157,12 @@ export default function ApplicantJobPage({ onNavigate }) {
|
|
| 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 |
|
|
@@ -203,18 +203,18 @@ export default function ApplicantJobPage({ onNavigate }) {
|
|
| 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',
|
|
|
|
| 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 |
|
|
|
|
| 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',
|