Initial commit
This commit is contained in:
+385
@@ -0,0 +1,385 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
JobApplication,
|
||||
JobFeedItem,
|
||||
UserProfile,
|
||||
NotificationLog,
|
||||
CalendarInterviewEvent,
|
||||
ApplicationStatus
|
||||
} from './types';
|
||||
import {
|
||||
INITIAL_USER_PROFILE,
|
||||
INITIAL_APPLICATIONS,
|
||||
INITIAL_JOB_FEEDS,
|
||||
INITIAL_CALENDAR_EVENTS,
|
||||
INITIAL_NOTIFICATIONS
|
||||
} from './data/mockData';
|
||||
import {
|
||||
fetchApplications,
|
||||
saveApplication,
|
||||
updateApplicationStatus,
|
||||
deleteApplicationRecord,
|
||||
fetchUserProfile,
|
||||
saveUserProfile,
|
||||
fetchNotifications,
|
||||
saveNotificationLog,
|
||||
fetchCalendarEvents,
|
||||
saveCalendarEvent
|
||||
} from './lib/firebase';
|
||||
|
||||
import { Navbar } from './components/Navbar';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { ApplicationsTracker } from './components/ApplicationsTracker';
|
||||
import { WalkthroughTailor } from './components/WalkthroughTailor';
|
||||
import { AutonomousEngine } from './components/AutonomousEngine';
|
||||
import { JobFeeds } from './components/JobFeeds';
|
||||
import { AnalyticsDashboard } from './components/AnalyticsDashboard';
|
||||
import { CalendarScheduler } from './components/CalendarScheduler';
|
||||
import { NotificationCenter } from './components/NotificationCenter';
|
||||
import { ProfileSettings } from './components/ProfileSettings';
|
||||
import { GoogleWorkspaceHub } from './components/GoogleWorkspaceHub';
|
||||
import { TailoredResumeModal } from './components/TailoredResumeModal';
|
||||
import { NewApplicationModal } from './components/NewApplicationModal';
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState<string>('applications');
|
||||
|
||||
// App state
|
||||
const [applications, setApplications] = useState<JobApplication[]>(INITIAL_APPLICATIONS);
|
||||
const [userProfile, setUserProfile] = useState<UserProfile>(INITIAL_USER_PROFILE);
|
||||
const [jobFeeds, setJobFeeds] = useState<JobFeedItem[]>(INITIAL_JOB_FEEDS);
|
||||
const [calendarEvents, setCalendarEvents] = useState<CalendarInterviewEvent[]>(INITIAL_CALENDAR_EVENTS);
|
||||
const [notifications, setNotifications] = useState<NotificationLog[]>(INITIAL_NOTIFICATIONS);
|
||||
|
||||
// Modals & UI States
|
||||
const [isAutoApplying, setIsAutoApplying] = useState(false);
|
||||
const [selectedAppForModal, setSelectedAppForModal] = useState<JobApplication | null>(null);
|
||||
const [isNewAppModalOpen, setIsNewAppModalOpen] = useState(false);
|
||||
const [walkthroughInitialUrl, setWalkthroughInitialUrl] = useState('');
|
||||
|
||||
// Initial Load from Firestore
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const firestoreApps = await fetchApplications();
|
||||
if (firestoreApps.length > 0) setApplications(firestoreApps);
|
||||
|
||||
const profile = await fetchUserProfile();
|
||||
if (profile) setUserProfile(profile);
|
||||
|
||||
const events = await fetchCalendarEvents();
|
||||
if (events.length > 0) setCalendarEvents(events);
|
||||
|
||||
const notifs = await fetchNotifications();
|
||||
if (notifs.length > 0) setNotifications(notifs);
|
||||
} catch (err) {
|
||||
console.warn('Initial load fallback to initial dataset:', err);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Handlers
|
||||
const handleUpdateStatus = async (id: string, newStatus: ApplicationStatus, note?: string) => {
|
||||
setApplications(prev => prev.map(a => a.id === id ? { ...a, status: newStatus } : a));
|
||||
await updateApplicationStatus(id, newStatus, note);
|
||||
|
||||
// Send email alert for important status updates
|
||||
if (['Interviewing', 'Offer', 'Screening'].includes(newStatus)) {
|
||||
const app = applications.find(a => a.id === id);
|
||||
if (app) {
|
||||
const notif = await saveNotificationLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'STATUS_CHANGE',
|
||||
recipient: userProfile.email,
|
||||
subject: `Application Status Alert: ${app.company} — ${newStatus}`,
|
||||
body: `Your job application for ${app.role} at ${app.company} has moved to ${newStatus}.`,
|
||||
status: 'SENT',
|
||||
platform: 'Gmail'
|
||||
});
|
||||
setNotifications(prev => [notif, ...prev]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteApplication = async (id: string) => {
|
||||
setApplications(prev => prev.filter(a => a.id !== id));
|
||||
await deleteApplicationRecord(id);
|
||||
};
|
||||
|
||||
const handleSaveApplicationRecord = async (appData: Omit<JobApplication, 'id'>) => {
|
||||
const saved = await saveApplication(appData);
|
||||
setApplications(prev => [saved, ...prev.filter(a => a.id !== saved.id)]);
|
||||
|
||||
// Notification alert
|
||||
const notif = await saveNotificationLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'AUTO_APPLY',
|
||||
recipient: userProfile.email,
|
||||
subject: `Application Logged: ${saved.company} (${saved.role})`,
|
||||
body: `Successfully logged application for ${saved.role} at ${saved.company} via ${saved.platform}. Match score: ${saved.matchScore}%.`,
|
||||
status: 'SENT',
|
||||
platform: 'Gmail'
|
||||
});
|
||||
setNotifications(prev => [notif, ...prev]);
|
||||
};
|
||||
|
||||
const handleSaveUserProfile = async (updated: UserProfile) => {
|
||||
setUserProfile(updated);
|
||||
await saveUserProfile(updated);
|
||||
};
|
||||
|
||||
const handleToggleAutoApply = () => {
|
||||
const updated = {
|
||||
...userProfile,
|
||||
autoApplyCriteria: {
|
||||
...userProfile.autoApplyCriteria,
|
||||
enabled: !userProfile.autoApplyCriteria.enabled
|
||||
}
|
||||
};
|
||||
handleSaveUserProfile(updated);
|
||||
};
|
||||
|
||||
// Run Autonomous Auto-Apply Engine Batch
|
||||
const handleRunAutoApplyBatch = async () => {
|
||||
setIsAutoApplying(true);
|
||||
try {
|
||||
const res = await fetch('/api/gemini/auto-apply-run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jobFeeds,
|
||||
userProfile,
|
||||
criteria: userProfile.autoApplyCriteria
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && Array.isArray(data.results)) {
|
||||
for (const item of data.results) {
|
||||
if (item.autoApplied) {
|
||||
const feed = jobFeeds.find(f => f.id === item.jobId);
|
||||
await handleSaveApplicationRecord({
|
||||
company: item.company,
|
||||
role: item.role,
|
||||
platform: feed?.platform || 'Greenhouse',
|
||||
location: feed?.location || 'San Francisco, CA',
|
||||
salaryRange: feed?.salary || '$180,000 - $220,000',
|
||||
status: 'Applied',
|
||||
matchScore: item.matchScore,
|
||||
appliedAt: new Date().toISOString(),
|
||||
jobUrl: feed?.url,
|
||||
jobDescription: feed?.description,
|
||||
notes: item.reason,
|
||||
autoApplied: true,
|
||||
timeline: [{ date: new Date().toLocaleDateString(), action: 'Autonomous AI Auto-Applied' }]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Autonomous auto-apply failed:', err);
|
||||
} finally {
|
||||
setIsAutoApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Schedule Interview
|
||||
const handleScheduleInterview = async (eventData: Omit<CalendarInterviewEvent, 'id'>) => {
|
||||
const res = await fetch('/api/calendar/schedule-interview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...eventData, userEmail: userProfile.email })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
const savedEvent = await saveCalendarEvent({
|
||||
...eventData,
|
||||
googleCalendarEventId: data.googleCalendarEventId,
|
||||
syncedToGoogle: true
|
||||
});
|
||||
|
||||
setCalendarEvents(prev => [...prev.filter(e => e.id !== savedEvent.id), savedEvent]);
|
||||
|
||||
// Update application status to Interviewing
|
||||
if (eventData.applicationId) {
|
||||
await handleUpdateStatus(eventData.applicationId, 'Interviewing', `Interview scheduled for ${new Date(eventData.startTime).toLocaleDateString()}`);
|
||||
}
|
||||
|
||||
// Log notification
|
||||
const notif = await saveNotificationLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'INTERVIEW_SCHEDULED',
|
||||
recipient: userProfile.email,
|
||||
subject: `Google Calendar Synced: ${eventData.company} Interview`,
|
||||
body: `Interview for ${eventData.role} at ${eventData.company} scheduled for ${new Date(eventData.startTime).toLocaleString()}. Synced to Google Calendar.`,
|
||||
status: 'SENT',
|
||||
platform: 'Google Calendar & Gmail'
|
||||
});
|
||||
setNotifications(prev => [notif, ...prev]);
|
||||
};
|
||||
|
||||
// Send test email
|
||||
const handleSendTestEmail = async (recipient: string, subject: string, body: string) => {
|
||||
const res = await fetch('/api/notifications/send-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipient, subject, body })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
const notif = await saveNotificationLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'SYSTEM',
|
||||
recipient,
|
||||
subject,
|
||||
body,
|
||||
status: 'SENT',
|
||||
platform: 'Gmail'
|
||||
});
|
||||
setNotifications(prev => [notif, ...prev]);
|
||||
};
|
||||
|
||||
// Navigate & Tailor
|
||||
const handleNavigateToWalkthrough = (jobUrl?: string) => {
|
||||
if (jobUrl) setWalkthroughInitialUrl(jobUrl);
|
||||
setActiveTab('walkthrough');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col font-sans antialiased selection:bg-indigo-500 selection:text-white">
|
||||
|
||||
{/* Top Navigation */}
|
||||
<Navbar
|
||||
activeTab={activeTab}
|
||||
autoApplyEnabled={userProfile.autoApplyCriteria.enabled}
|
||||
onToggleAutoApply={handleToggleAutoApply}
|
||||
onOpenNewAppModal={() => setIsNewAppModalOpen(true)}
|
||||
onRunAutoApplyNow={handleRunAutoApplyBatch}
|
||||
isAutoApplying={isAutoApplying}
|
||||
/>
|
||||
|
||||
{/* Main Container */}
|
||||
<div className="flex-1 flex flex-col md:flex-row max-w-7xl w-full mx-auto">
|
||||
|
||||
{/* Left Sidebar */}
|
||||
<Sidebar
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
pendingCount={applications.filter(a => ['Applied', 'Screening'].includes(a.status)).length}
|
||||
autoAppliedCount={applications.filter(a => a.autoApplied).length}
|
||||
/>
|
||||
|
||||
{/* Main Workspace Area */}
|
||||
<main className="flex-1 p-4 sm:p-6 overflow-y-auto">
|
||||
{activeTab === 'applications' && (
|
||||
<ApplicationsTracker
|
||||
applications={applications}
|
||||
onUpdateStatus={handleUpdateStatus}
|
||||
onDeleteApplication={handleDeleteApplication}
|
||||
onViewTailoredResume={(app) => setSelectedAppForModal(app)}
|
||||
onOpenNewAppModal={() => setIsNewAppModalOpen(true)}
|
||||
onNavigateToWalkthrough={handleNavigateToWalkthrough}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'walkthrough' && (
|
||||
<WalkthroughTailor
|
||||
userProfile={userProfile}
|
||||
initialJobUrl={walkthroughInitialUrl}
|
||||
onSaveTailoredApplication={handleSaveApplicationRecord}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'auto-apply' && (
|
||||
<AutonomousEngine
|
||||
userProfile={userProfile}
|
||||
jobFeeds={jobFeeds}
|
||||
autoAppliedApps={applications.filter(a => a.autoApplied)}
|
||||
onSaveProfile={handleSaveUserProfile}
|
||||
onRunAutoApplyBatch={handleRunAutoApplyBatch}
|
||||
isAutoApplying={isAutoApplying}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'feeds' && (
|
||||
<JobFeeds
|
||||
jobFeeds={jobFeeds}
|
||||
onTailorJobFeed={(item) => handleNavigateToWalkthrough(item.url)}
|
||||
onQuickApplyFeed={async (item) => {
|
||||
await handleSaveApplicationRecord({
|
||||
company: item.company,
|
||||
role: item.title,
|
||||
platform: item.platform,
|
||||
location: item.location,
|
||||
salaryRange: item.salary,
|
||||
status: 'Applied',
|
||||
matchScore: item.estimatedMatch,
|
||||
appliedAt: new Date().toISOString(),
|
||||
jobUrl: item.url,
|
||||
jobDescription: item.description,
|
||||
autoApplied: true,
|
||||
timeline: [{ date: new Date().toLocaleDateString(), action: 'Quick AI Auto-Applied from feed' }]
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<AnalyticsDashboard applications={applications} />
|
||||
)}
|
||||
|
||||
{activeTab === 'calendar' && (
|
||||
<CalendarScheduler
|
||||
events={calendarEvents}
|
||||
applications={applications}
|
||||
onScheduleInterview={handleScheduleInterview}
|
||||
userEmail={userProfile.email}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<NotificationCenter
|
||||
notifications={notifications}
|
||||
onSendTestEmail={handleSendTestEmail}
|
||||
userEmail={userProfile.email}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'profile' && (
|
||||
<ProfileSettings
|
||||
userProfile={userProfile}
|
||||
onSaveProfile={handleSaveUserProfile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'workspace' && (
|
||||
<GoogleWorkspaceHub
|
||||
applications={applications}
|
||||
userEmail={userProfile.email}
|
||||
userName={userProfile.fullName}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Tailored Resume Inspection Modal */}
|
||||
{selectedAppForModal && (
|
||||
<TailoredResumeModal
|
||||
application={selectedAppForModal}
|
||||
onClose={() => setSelectedAppForModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New Application Modal */}
|
||||
{isNewAppModalOpen && (
|
||||
<NewApplicationModal
|
||||
onClose={() => setIsNewAppModalOpen(false)}
|
||||
onSaveApplication={handleSaveApplicationRecord}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import React from 'react';
|
||||
import { JobApplication, PlatformType } from '../types';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
CartesianGrid
|
||||
} from 'recharts';
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Award,
|
||||
Target,
|
||||
Building2,
|
||||
PieChart as PieIcon,
|
||||
Zap,
|
||||
Bot
|
||||
} from 'lucide-react';
|
||||
|
||||
interface AnalyticsDashboardProps {
|
||||
applications: JobApplication[];
|
||||
}
|
||||
|
||||
export const AnalyticsDashboard: React.FC<AnalyticsDashboardProps> = ({ applications }) => {
|
||||
|
||||
// Calculate Conversion Funnel Data
|
||||
const totalTracked = applications.length;
|
||||
const totalApplied = applications.filter(a => a.status !== 'Saved').length;
|
||||
const totalScreening = applications.filter(a => ['Screening', 'Interviewing', 'Offer'].includes(a.status)).length;
|
||||
const totalInterviewing = applications.filter(a => ['Interviewing', 'Offer'].includes(a.status)).length;
|
||||
const totalOffers = applications.filter(a => a.status === 'Offer').length;
|
||||
|
||||
const funnelData = [
|
||||
{ stage: 'Discovered', count: totalTracked, fill: '#6366f1' },
|
||||
{ stage: 'Applied', count: totalApplied, fill: '#3b82f6' },
|
||||
{ stage: 'Screening', count: totalScreening, fill: '#06b6d4' },
|
||||
{ stage: 'Interviewing', count: totalInterviewing, fill: '#10b981' },
|
||||
{ stage: 'Offers', count: totalOffers, fill: '#34d399' },
|
||||
];
|
||||
|
||||
// Platform performance break down
|
||||
const platformsList: PlatformType[] = ['LinkedIn', 'Greenhouse', 'Lever', 'Workday', 'Glassdoor', 'Indeed', 'Direct'];
|
||||
const platformData = platformsList.map(platform => {
|
||||
const apps = applications.filter(a => a.platform === platform);
|
||||
const count = apps.length;
|
||||
const interviews = apps.filter(a => ['Interviewing', 'Offer'].includes(a.status)).length;
|
||||
const successRate = count > 0 ? Math.round((interviews / count) * 100) : 0;
|
||||
|
||||
return {
|
||||
platform,
|
||||
count,
|
||||
interviews,
|
||||
successRate
|
||||
};
|
||||
}).filter(p => p.count > 0);
|
||||
|
||||
// Match Score Correlation Data
|
||||
const matchScoreData = [
|
||||
{ range: '90-100%', count: applications.filter(a => a.matchScore >= 90).length, interviews: applications.filter(a => a.matchScore >= 90 && ['Interviewing', 'Offer'].includes(a.status)).length },
|
||||
{ range: '80-89%', count: applications.filter(a => a.matchScore >= 80 && a.matchScore < 90).length, interviews: applications.filter(a => a.matchScore >= 80 && a.matchScore < 90 && ['Interviewing', 'Offer'].includes(a.status)).length },
|
||||
{ range: '<80%', count: applications.filter(a => a.matchScore < 80).length, interviews: applications.filter(a => a.matchScore < 80 && ['Interviewing', 'Offer'].includes(a.status)).length }
|
||||
];
|
||||
|
||||
// Weekly Application Velocity mock trend
|
||||
const velocityData = [
|
||||
{ week: 'Wk 1', manual: 3, auto: 2 },
|
||||
{ week: 'Wk 2', manual: 4, auto: 5 },
|
||||
{ week: 'Wk 3', manual: 2, auto: 8 },
|
||||
{ week: 'Wk 4 (Current)', manual: 4, auto: 12 },
|
||||
];
|
||||
|
||||
// Key KPI metrics
|
||||
const avgMatchScore = applications.length > 0
|
||||
? Math.round(applications.reduce((acc, a) => acc + a.matchScore, 0) / applications.length)
|
||||
: 0;
|
||||
|
||||
const interviewRate = totalApplied > 0
|
||||
? Math.round((totalInterviewing / totalApplied) * 100)
|
||||
: 0;
|
||||
|
||||
const offerRate = totalApplied > 0
|
||||
? Math.round((totalOffers / totalApplied) * 100)
|
||||
: 0;
|
||||
|
||||
const autoApplyRatio = applications.length > 0
|
||||
? Math.round((applications.filter(a => a.autoApplied).length / applications.length) * 100)
|
||||
: 0;
|
||||
|
||||
const COLORS = ['#6366f1', '#3b82f6', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Top Banner & Key KPI Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
|
||||
<div className="p-5 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold">
|
||||
<span>Interview Rate</span>
|
||||
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-extrabold text-white mt-2">{interviewRate}%</div>
|
||||
<div className="text-[11px] text-slate-500 mt-1">Applications converting to interview</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold">
|
||||
<span>Average AI Match</span>
|
||||
<Target className="w-4 h-4 text-indigo-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-extrabold text-indigo-300 mt-2">{avgMatchScore}%</div>
|
||||
<div className="text-[11px] text-slate-500 mt-1">Gemini relevance score across portal</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold">
|
||||
<span>Offer Win Rate</span>
|
||||
<Award className="w-4 h-4 text-amber-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-extrabold text-emerald-300 mt-2">{offerRate}%</div>
|
||||
<div className="text-[11px] text-slate-500 mt-1">Total submitted to written offer</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold">
|
||||
<span>Autonomous Automation</span>
|
||||
<Bot className="w-4 h-4 text-cyan-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-extrabold text-cyan-300 mt-2">{autoApplyRatio}%</div>
|
||||
<div className="text-[11px] text-slate-500 mt-1">Applications submitted by AI engine</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Main Charts Row 1: Conversion Funnel & Platform Success */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* Conversion Funnel Bar Chart */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl">
|
||||
<div className="flex items-center justify-between pb-4 mb-4 border-b border-slate-800">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-indigo-400" /> Application Conversion Funnel
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">Progression from discovery to final job offer</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={funnelData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.5} />
|
||||
<XAxis dataKey="stage" stroke="#94a3b8" fontSize={11} tickLine={false} />
|
||||
<YAxis stroke="#94a3b8" fontSize={11} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px', fontSize: '12px', color: '#fff' }}
|
||||
cursor={{ fill: 'rgba(255, 255, 255, 0.05)' }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[8, 8, 0, 0]}>
|
||||
{funnelData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Success Rate Chart */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl">
|
||||
<div className="flex items-center justify-between pb-4 mb-4 border-b border-slate-800">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-blue-400" /> Interview Success Rate % by Platform
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">Which job platforms produce the highest interview responses</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={platformData} layout="vertical" margin={{ top: 10, right: 20, left: 20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.5} />
|
||||
<XAxis type="number" stroke="#94a3b8" fontSize={11} unit="%" />
|
||||
<YAxis dataKey="platform" type="category" stroke="#94a3b8" fontSize={11} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px', fontSize: '12px', color: '#fff' }}
|
||||
cursor={{ fill: 'rgba(255, 255, 255, 0.05)' }}
|
||||
/>
|
||||
<Bar dataKey="successRate" fill="#38bdf8" radius={[0, 8, 8, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Main Charts Row 2: Match Score vs Interviews & Application Velocity */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* Match Score Impact */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl">
|
||||
<div className="flex items-center justify-between pb-4 mb-4 border-b border-slate-800">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-amber-400" /> Match Score vs. Interview Yield
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">Higher AI match score directly correlates to higher interview calls</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-60">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={matchScoreData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.5} />
|
||||
<XAxis dataKey="range" stroke="#94a3b8" fontSize={11} />
|
||||
<YAxis stroke="#94a3b8" fontSize={11} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px', fontSize: '12px', color: '#fff' }}
|
||||
/>
|
||||
<Bar dataKey="count" name="Total Submissions" fill="#6366f1" radius={[6, 6, 0, 0]} />
|
||||
<Bar dataKey="interviews" name="Interviews Yielded" fill="#10b981" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Velocity */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl">
|
||||
<div className="flex items-center justify-between pb-4 mb-4 border-b border-slate-800">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-400" /> Weekly Application Velocity
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">Comparison of Manual Walkthrough vs Autonomous Auto-Apply submissions</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-60">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={velocityData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.5} />
|
||||
<XAxis dataKey="week" stroke="#94a3b8" fontSize={11} />
|
||||
<YAxis stroke="#94a3b8" fontSize={11} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px', fontSize: '12px', color: '#fff' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="auto" name="AI Auto-Apply" stroke="#818cf8" fill="#6366f1" fillOpacity={0.4} />
|
||||
<Area type="monotone" dataKey="manual" name="Manual Tailor" stroke="#38bdf8" fill="#0284c7" fillOpacity={0.3} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,456 @@
|
||||
import React, { useState } from 'react';
|
||||
import { JobApplication, ApplicationStatus, PlatformType } from '../types';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Calendar,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
XCircle,
|
||||
Award,
|
||||
Trash2,
|
||||
Download,
|
||||
Plus,
|
||||
Eye,
|
||||
Building2,
|
||||
Sparkles,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ApplicationsTrackerProps {
|
||||
applications: JobApplication[];
|
||||
onUpdateStatus: (id: string, newStatus: ApplicationStatus, note?: string) => void;
|
||||
onDeleteApplication: (id: string) => void;
|
||||
onViewTailoredResume: (app: JobApplication) => void;
|
||||
onOpenNewAppModal: () => void;
|
||||
onNavigateToWalkthrough: (jobUrl?: string) => void;
|
||||
}
|
||||
|
||||
export const ApplicationsTracker: React.FC<ApplicationsTrackerProps> = ({
|
||||
applications,
|
||||
onUpdateStatus,
|
||||
onDeleteApplication,
|
||||
onViewTailoredResume,
|
||||
onOpenNewAppModal,
|
||||
onNavigateToWalkthrough
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||
const [platformFilter, setPlatformFilter] = useState<string>('ALL');
|
||||
const [autoOnlyFilter, setAutoOnlyFilter] = useState<boolean>(false);
|
||||
const [selectedAppForDetail, setSelectedAppForDetail] = useState<JobApplication | null>(null);
|
||||
|
||||
const statuses: ApplicationStatus[] = ['Saved', 'Applying', 'Applied', 'Screening', 'Interviewing', 'Offer', 'Rejected', 'Withdrawn'];
|
||||
const platforms: PlatformType[] = ['LinkedIn', 'Indeed', 'Glassdoor', 'Greenhouse', 'Lever', 'Workday', 'Direct'];
|
||||
|
||||
// Filtered applications
|
||||
const filteredApps = applications.filter(app => {
|
||||
const matchesSearch =
|
||||
app.company.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
app.role.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
app.location.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'ALL' || app.status === statusFilter;
|
||||
const matchesPlatform = platformFilter === 'ALL' || app.platform === platformFilter;
|
||||
const matchesAuto = !autoOnlyFilter || app.autoApplied === true;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesPlatform && matchesAuto;
|
||||
});
|
||||
|
||||
// Export helper
|
||||
const handleExportCSV = () => {
|
||||
const headers = ['Company', 'Role', 'Platform', 'Status', 'MatchScore%', 'AppliedAt', 'Location', 'Salary', 'AutoApplied'];
|
||||
const rows = filteredApps.map(a => [
|
||||
`"${a.company}"`,
|
||||
`"${a.role}"`,
|
||||
`"${a.platform}"`,
|
||||
`"${a.status}"`,
|
||||
a.matchScore,
|
||||
`"${a.appliedAt}"`,
|
||||
`"${a.location}"`,
|
||||
`"${a.salaryRange || 'N/A'}"`,
|
||||
a.autoApplied ? 'Yes' : 'No'
|
||||
]);
|
||||
const csvContent = "data:text/csv;charset=utf-8," + [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', encodedUri);
|
||||
link.setAttribute('download', `job_applications_export_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ApplicationStatus) => {
|
||||
switch (status) {
|
||||
case 'Offer':
|
||||
return 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40 font-bold';
|
||||
case 'Interviewing':
|
||||
return 'bg-blue-500/20 text-blue-300 border-blue-500/40 font-bold';
|
||||
case 'Screening':
|
||||
return 'bg-indigo-500/20 text-indigo-300 border-indigo-500/40';
|
||||
case 'Applied':
|
||||
return 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30';
|
||||
case 'Applying':
|
||||
case 'Saved':
|
||||
return 'bg-amber-500/20 text-amber-300 border-amber-500/30';
|
||||
case 'Rejected':
|
||||
return 'bg-rose-500/20 text-rose-300 border-rose-500/30';
|
||||
case 'Withdrawn':
|
||||
return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||
default:
|
||||
return 'bg-slate-700 text-slate-300 border-slate-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-[#space-y-6] flex flex-col gap-6">
|
||||
|
||||
{/* Top Banner / Summary metrics */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="p-4 rounded-2xl bg-slate-900 border border-slate-800 flex flex-col justify-between">
|
||||
<div className="text-xs font-semibold text-slate-400">Total Tracked</div>
|
||||
<div className="text-2xl font-extrabold text-white mt-1">{applications.length}</div>
|
||||
<div className="text-[11px] text-slate-500 mt-1">Across {new Set(applications.map(a => a.company)).size} Companies</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-2xl bg-slate-900 border border-slate-800 flex flex-col justify-between">
|
||||
<div className="text-xs font-semibold text-blue-400 flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" /> Interviewing
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-blue-300 mt-1">
|
||||
{applications.filter(a => a.status === 'Interviewing' || a.status === 'Screening').length}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 mt-1">Active Pipeline</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-2xl bg-slate-900 border border-slate-800 flex flex-col justify-between">
|
||||
<div className="text-xs font-semibold text-emerald-400 flex items-center gap-1">
|
||||
<Award className="w-3.5 h-3.5" /> Offers Received
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-emerald-300 mt-1">
|
||||
{applications.filter(a => a.status === 'Offer').length}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 mt-1">Conversion Win</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-2xl bg-slate-900 border border-slate-800 flex flex-col justify-between">
|
||||
<div className="text-xs font-semibold text-indigo-400 flex items-center gap-1">
|
||||
<Bot className="w-3.5 h-3.5 text-indigo-400" /> AI Auto-Applied
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-indigo-300 mt-1">
|
||||
{applications.filter(a => a.autoApplied).length}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 mt-1">Autonomous Submissions</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search & Filter Control Bar */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900 border border-slate-800 flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative flex-1 min-w-[240px]">
|
||||
<Search className="w-4 h-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by company, role title, or location..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs placeholder:text-slate-500 focus:outline-none focus:border-indigo-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters group */}
|
||||
<div className="flex items-center flex-wrap gap-2.5 text-xs">
|
||||
|
||||
{/* Status filter dropdown */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-300 text-xs focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="ALL">All Statuses</option>
|
||||
{statuses.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Platform filter dropdown */}
|
||||
<select
|
||||
value={platformFilter}
|
||||
onChange={(e) => setPlatformFilter(e.target.value)}
|
||||
className="px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-300 text-xs focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="ALL">All Platforms</option>
|
||||
{platforms.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Auto-apply toggle checkbox */}
|
||||
<button
|
||||
onClick={() => setAutoOnlyFilter(!autoOnlyFilter)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl border font-medium text-xs transition-all ${
|
||||
autoOnlyFilter
|
||||
? 'bg-indigo-950/80 border-indigo-500/50 text-indigo-300'
|
||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Bot className="w-3.5 h-3.5 text-indigo-400" />
|
||||
<span>Auto-Applied Only</span>
|
||||
</button>
|
||||
|
||||
{/* Export CSV button */}
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 transition-all"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Main Applications Table */}
|
||||
<div className="rounded-2xl bg-slate-900 border border-slate-800 overflow-hidden shadow-xl">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs text-slate-300">
|
||||
<thead className="bg-slate-950/80 text-slate-400 border-b border-slate-800 uppercase font-semibold text-[11px] tracking-wider">
|
||||
<tr>
|
||||
<th className="py-3.5 px-4">Company & Role</th>
|
||||
<th className="py-3.5 px-4">Platform</th>
|
||||
<th className="py-3.5 px-4">Match %</th>
|
||||
<th className="py-3.5 px-4">Status</th>
|
||||
<th className="py-3.5 px-4">Date Applied</th>
|
||||
<th className="py-3.5 px-4">Tailored Artifacts</th>
|
||||
<th className="py-3.5 px-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/80">
|
||||
{filteredApps.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-12 text-center text-slate-500">
|
||||
<Building2 className="w-8 h-8 mx-auto text-slate-600 mb-2" />
|
||||
<p className="font-semibold text-slate-400">No applications match your filter query.</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Try resetting filters or add a new application.</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredApps.map((app) => (
|
||||
<tr key={app.id} className="hover:bg-slate-800/40 transition-colors group">
|
||||
|
||||
{/* Company & Role */}
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-800 border border-slate-700/80 flex items-center justify-center font-bold text-slate-200 shrink-0 mt-0.5">
|
||||
{app.company.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-white group-hover:text-indigo-300 transition-colors flex items-center gap-1.5">
|
||||
{app.role}
|
||||
{app.autoApplied && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.2 rounded text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30" title="Autonomous AI Applied">
|
||||
<Bot className="w-2.5 h-2.5" /> AI
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 mt-0.5 flex items-center gap-2">
|
||||
<span>{app.company}</span>
|
||||
<span>•</span>
|
||||
<span>{app.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Platform */}
|
||||
<td className="py-3.5 px-4 font-medium text-slate-300">
|
||||
<span className="px-2.5 py-1 rounded-lg bg-slate-800/80 border border-slate-700/60 text-slate-300">
|
||||
{app.platform}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Match % */}
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-12 bg-slate-800 h-2 rounded-full overflow-hidden border border-slate-700">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
app.matchScore >= 90 ? 'bg-emerald-400' :
|
||||
app.matchScore >= 80 ? 'bg-indigo-400' : 'bg-amber-400'
|
||||
}`}
|
||||
style={{ width: `${app.matchScore}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-semibold text-slate-200">{app.matchScore}%</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Status Dropdown */}
|
||||
<td className="py-3.5 px-4">
|
||||
<select
|
||||
value={app.status}
|
||||
onChange={(e) => onUpdateStatus(app.id, e.target.value as ApplicationStatus)}
|
||||
className={`px-2.5 py-1 rounded-lg border text-xs font-semibold focus:outline-none cursor-pointer ${getStatusBadge(app.status)}`}
|
||||
>
|
||||
{statuses.map(s => <option key={s} value={s} className="bg-slate-900 text-slate-200">{s}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
|
||||
{/* Date Applied */}
|
||||
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
|
||||
{new Date(app.appliedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</td>
|
||||
|
||||
{/* Tailored Artifacts */}
|
||||
<td className="py-3.5 px-4">
|
||||
<button
|
||||
onClick={() => onViewTailoredResume(app)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-indigo-950/60 hover:bg-indigo-900/60 text-indigo-300 border border-indigo-500/30 transition-all text-xs font-medium"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
<span>View Resume & Cover</span>
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="py-3.5 px-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
|
||||
{/* Direct Job Link */}
|
||||
{app.jobUrl && (
|
||||
<a
|
||||
href={app.jobUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-all"
|
||||
title="Open external job listing"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* View Details Drawer Button */}
|
||||
<button
|
||||
onClick={() => setSelectedAppForDetail(app)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-all"
|
||||
title="View complete application details & timeline"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Delete Application Button */}
|
||||
<button
|
||||
onClick={() => onDeleteApplication(app.id)}
|
||||
className="p-1.5 rounded-lg text-rose-400/80 hover:text-rose-300 hover:bg-rose-950/50 transition-all"
|
||||
title="Delete application record"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drawer / Modal for Application Details */}
|
||||
{selectedAppForDetail && (
|
||||
<div className="fixed inset-0 z-50 bg-slate-950/80 backdrop-blur-sm flex justify-end">
|
||||
<div className="w-full max-w-xl bg-slate-900 border-l border-slate-800 h-full overflow-y-auto p-6 flex flex-col justify-between shadow-2xl">
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-4 border-b border-slate-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-600/20 text-indigo-400 border border-indigo-500/30 flex items-center justify-center font-bold text-lg">
|
||||
{selectedAppForDetail.company.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">{selectedAppForDetail.role}</h3>
|
||||
<p className="text-xs text-slate-400">{selectedAppForDetail.company} • {selectedAppForDetail.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedAppForDetail(null)}
|
||||
className="text-slate-400 hover:text-white p-2 rounded-lg bg-slate-800"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status & Key Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 my-4 text-xs">
|
||||
<div className="p-3 rounded-xl bg-slate-950 border border-slate-800">
|
||||
<div className="text-slate-500 mb-1">Status</div>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-bold ${getStatusBadge(selectedAppForDetail.status)}`}>
|
||||
{selectedAppForDetail.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-slate-950 border border-slate-800">
|
||||
<div className="text-slate-500 mb-1">AI Match Score</div>
|
||||
<div className="font-bold text-indigo-300 text-sm">{selectedAppForDetail.matchScore}% Match</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job Description Summary */}
|
||||
{selectedAppForDetail.jobDescription && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 mb-2">Job Overview</h4>
|
||||
<p className="text-xs text-slate-300 bg-slate-950 p-3 rounded-xl border border-slate-800 leading-relaxed">
|
||||
{selectedAppForDetail.jobDescription}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Application Timeline */}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 mb-2">Application Timeline</h4>
|
||||
<div className="space-y-2 border-l-2 border-indigo-500/30 pl-3 ml-1">
|
||||
{(selectedAppForDetail.timeline || [
|
||||
{ date: new Date(selectedAppForDetail.appliedAt).toLocaleDateString(), action: 'Submitted application' }
|
||||
]).map((t, idx) => (
|
||||
<div key={idx} className="text-xs">
|
||||
<div className="font-semibold text-slate-200">{t.action}</div>
|
||||
<div className="text-[11px] text-slate-500">{t.date}</div>
|
||||
{t.details && <div className="text-[11px] text-indigo-300 mt-0.5">{t.details}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{selectedAppForDetail.notes && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 mb-2">Notes</h4>
|
||||
<div className="text-xs text-slate-300 bg-slate-950 p-3 rounded-xl border border-slate-800">
|
||||
{selectedAppForDetail.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="pt-4 border-t border-slate-800 flex items-center justify-between gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
onViewTailoredResume(selectedAppForDetail);
|
||||
setSelectedAppForDetail(null);
|
||||
}}
|
||||
className="flex-1 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs flex items-center justify-center gap-2"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>View Tailored Resume</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,325 @@
|
||||
import React, { useState } from 'react';
|
||||
import { UserProfile, JobFeedItem, JobApplication } from '../types';
|
||||
import {
|
||||
Bot,
|
||||
Play,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Sliders,
|
||||
Sparkles,
|
||||
Clock,
|
||||
ShieldCheck,
|
||||
AlertTriangle,
|
||||
Terminal,
|
||||
Activity,
|
||||
Zap,
|
||||
Tag
|
||||
} from 'lucide-react';
|
||||
|
||||
interface AutonomousEngineProps {
|
||||
userProfile: UserProfile;
|
||||
jobFeeds: JobFeedItem[];
|
||||
autoAppliedApps: JobApplication[];
|
||||
onSaveProfile: (updatedProfile: UserProfile) => void;
|
||||
onRunAutoApplyBatch: () => Promise<void>;
|
||||
isAutoApplying: boolean;
|
||||
}
|
||||
|
||||
export const AutonomousEngine: React.FC<AutonomousEngineProps> = ({
|
||||
userProfile,
|
||||
jobFeeds,
|
||||
autoAppliedApps,
|
||||
onSaveProfile,
|
||||
onRunAutoApplyBatch,
|
||||
isAutoApplying
|
||||
}) => {
|
||||
const [criteria, setCriteria] = useState(userProfile.autoApplyCriteria);
|
||||
const [targetRoleInput, setTargetRoleInput] = useState('');
|
||||
const [excludedCompanyInput, setExcludedCompanyInput] = useState('');
|
||||
|
||||
const [logs, setLogs] = useState<{ id: string; time: string; msg: string; type: 'info' | 'success' | 'warning' }[]>([
|
||||
{ id: '1', time: '10:15 AM', msg: 'Autonomous AI Daemon initialized with Gemini 3.6 Flash.', type: 'info' },
|
||||
{ id: '2', time: '10:15 AM', msg: 'Criteria synced: Min Match Score 82%, Max Daily 10 Apps.', type: 'info' },
|
||||
{ id: '3', time: '09:15 AM', msg: 'Auto-Submitted: Anthropic - AI Agent Engineer (Match: 89%). Email alert sent to Gmail.', type: 'success' },
|
||||
{ id: '4', time: '08:30 AM', msg: 'Auto-Submitted: Airbnb - Sr Staff Full Stack Architect (Match: 88%). Email alert sent.', type: 'success' }
|
||||
]);
|
||||
|
||||
const handleToggleEnabled = () => {
|
||||
const updated = { ...criteria, enabled: !criteria.enabled };
|
||||
setCriteria(updated);
|
||||
onSaveProfile({ ...userProfile, autoApplyCriteria: updated });
|
||||
};
|
||||
|
||||
const handleUpdateCriteria = (key: keyof typeof criteria, val: any) => {
|
||||
const updated = { ...criteria, [key]: val };
|
||||
setCriteria(updated);
|
||||
onSaveProfile({ ...userProfile, autoApplyCriteria: updated });
|
||||
};
|
||||
|
||||
const handleAddTargetRole = () => {
|
||||
if (!targetRoleInput.trim()) return;
|
||||
const updatedRoles = [...criteria.targetRoles, targetRoleInput.trim()];
|
||||
handleUpdateCriteria('targetRoles', updatedRoles);
|
||||
setTargetRoleInput('');
|
||||
};
|
||||
|
||||
const handleRemoveTargetRole = (role: string) => {
|
||||
const updatedRoles = criteria.targetRoles.filter(r => r !== role);
|
||||
handleUpdateCriteria('targetRoles', updatedRoles);
|
||||
};
|
||||
|
||||
const handleAddExcludedCompany = () => {
|
||||
if (!excludedCompanyInput.trim()) return;
|
||||
const updated = [...criteria.excludedCompanies, excludedCompanyInput.trim()];
|
||||
handleUpdateCriteria('excludedCompanies', updated);
|
||||
setExcludedCompanyInput('');
|
||||
};
|
||||
|
||||
const handleRemoveExcludedCompany = (comp: string) => {
|
||||
const updated = criteria.excludedCompanies.filter(c => c !== comp);
|
||||
handleUpdateCriteria('excludedCompanies', updated);
|
||||
};
|
||||
|
||||
const handleTriggerRun = async () => {
|
||||
const newLog = { id: `${Date.now()}`, time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), msg: 'Triggering real-time autonomous evaluation on active feeds...', type: 'info' as const };
|
||||
setLogs(prev => [newLog, ...prev]);
|
||||
|
||||
await onRunAutoApplyBatch();
|
||||
|
||||
const successLog = { id: `${Date.now() + 1}`, time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), msg: 'Batch evaluation complete. New qualified applications submitted & logged.', type: 'success' as const };
|
||||
setLogs(prev => [successLog, ...prev]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Top Banner Status & Control */}
|
||||
<div className="p-6 rounded-2xl bg-gradient-to-r from-slate-900 via-indigo-950/60 to-slate-900 border border-indigo-500/30 shadow-2xl flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center font-extrabold shadow-lg ${
|
||||
criteria.enabled
|
||||
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/40 shadow-emerald-500/10'
|
||||
: 'bg-slate-800 text-slate-500 border border-slate-700'
|
||||
}`}>
|
||||
<Bot className={`w-8 h-8 ${criteria.enabled ? 'animate-pulse' : ''}`} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-bold text-white">Autonomous AI Auto-Apply Engine</h2>
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold border ${
|
||||
criteria.enabled ? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40' : 'bg-slate-800 text-slate-400 border-slate-700'
|
||||
}`}>
|
||||
{criteria.enabled ? 'ENGINE ONLINE' : 'ENGINE STANDBY'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1 max-w-xl">
|
||||
Continuously scans active job feeds, computes multi-dimensional match scores with Gemini AI, generates tailored resumes, and auto-submits applications safely according to your strict rules.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={handleToggleEnabled}
|
||||
className={`px-4 py-2.5 rounded-xl font-bold text-xs transition-all border ${
|
||||
criteria.enabled
|
||||
? 'bg-rose-950/80 border-rose-500/50 text-rose-300 hover:bg-rose-900/80'
|
||||
: 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-600/20'
|
||||
}`}
|
||||
>
|
||||
{criteria.enabled ? 'Pause Daemon' : 'Enable Autonomous Daemon'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleTriggerRun}
|
||||
disabled={isAutoApplying}
|
||||
className="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs shadow-lg shadow-indigo-600/30 disabled:opacity-50 flex items-center gap-2 transition-all"
|
||||
>
|
||||
<Play className={`w-3.5 h-3.5 ${isAutoApplying ? 'animate-spin' : ''}`} />
|
||||
<span>{isAutoApplying ? 'Evaluating Jobs...' : 'Trigger AI Run Now'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Main Grid: Controls vs Live Decision Terminal */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* Criteria Settings Form */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl space-y-5">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Sliders className="w-4 h-4 text-indigo-400" /> Criteria & Filters
|
||||
</h3>
|
||||
<span className="text-[11px] text-slate-500">Auto-saved to Firebase</span>
|
||||
</div>
|
||||
|
||||
{/* Min Match Score & Max Daily Applications */}
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1.5">
|
||||
Minimum Match Threshold: <strong className="text-indigo-400">{criteria.minMatchScore}%</strong>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="60"
|
||||
max="95"
|
||||
step="1"
|
||||
value={criteria.minMatchScore}
|
||||
onChange={(e) => handleUpdateCriteria('minMatchScore', Number(e.target.value))}
|
||||
className="w-full accent-indigo-500 cursor-pointer"
|
||||
/>
|
||||
<span className="text-[10px] text-slate-500">Only jobs with ≥{criteria.minMatchScore}% score qualify</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1.5">
|
||||
Max Daily Submissions: <strong className="text-emerald-400">{criteria.maxDailyApplications}</strong>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="25"
|
||||
value={criteria.maxDailyApplications}
|
||||
onChange={(e) => handleUpdateCriteria('maxDailyApplications', Number(e.target.value))}
|
||||
className="w-full px-3 py-1.5 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
<span className="text-[10px] text-slate-500">Protects platform rate limits</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Role Tags */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Target Job Titles</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. AI Engineer"
|
||||
value={targetRoleInput}
|
||||
onChange={(e) => setTargetRoleInput(e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddTargetRole}
|
||||
className="px-3 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold"
|
||||
>
|
||||
Add Role
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{criteria.targetRoles.map((role, idx) => (
|
||||
<span key={idx} className="px-2.5 py-1 rounded-lg bg-indigo-950/80 text-indigo-300 border border-indigo-500/30 text-xs flex items-center gap-1.5">
|
||||
<Tag className="w-3 h-3 text-indigo-400" />
|
||||
{role}
|
||||
<button onClick={() => handleRemoveTargetRole(role)} className="hover:text-white ml-1">✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Excluded Companies */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Excluded / Blacklisted Companies</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Legacy Systems Corp"
|
||||
value={excludedCompanyInput}
|
||||
onChange={(e) => setExcludedCompanyInput(e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddExcludedCompany}
|
||||
className="px-3 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold"
|
||||
>
|
||||
Exclude
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{criteria.excludedCompanies.map((comp, idx) => (
|
||||
<span key={idx} className="px-2.5 py-1 rounded-lg bg-rose-950/80 text-rose-300 border border-rose-500/30 text-xs flex items-center gap-1.5">
|
||||
{comp}
|
||||
<button onClick={() => handleRemoveExcludedCompany(comp)} className="hover:text-white ml-1">✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Real-time AI Execution Console / Logs */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-3 mb-3 border-b border-slate-800">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-emerald-400" /> Real-Time Decision Console
|
||||
</h3>
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-400">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-ping"></span> Live Agent Feed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3.5 rounded-xl bg-slate-950 border border-slate-800 font-mono text-[11px] space-y-2 h-[280px] overflow-y-auto">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="flex items-start gap-2">
|
||||
<span className="text-slate-500 shrink-0">[{log.time}]</span>
|
||||
<span className={
|
||||
log.type === 'success' ? 'text-emerald-400 font-medium' :
|
||||
log.type === 'warning' ? 'text-amber-400' : 'text-slate-300'
|
||||
}>
|
||||
{log.msg}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-slate-800 flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Evaluated Job Queue: <strong>{jobFeeds.length} items</strong></span>
|
||||
<span>Auto-Submitted Total: <strong className="text-indigo-400">{autoAppliedApps.length}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Auto-Submitted Applications Grid */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl">
|
||||
<h3 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-indigo-400" />
|
||||
Autonomous AI Submissions History ({autoAppliedApps.length})
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 text-xs">
|
||||
{autoAppliedApps.length === 0 ? (
|
||||
<p className="text-slate-500 col-span-full text-center py-6">No autonomous applications submitted yet.</p>
|
||||
) : (
|
||||
autoAppliedApps.map((app) => (
|
||||
<div key={app.id} className="p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-indigo-500/40 transition-all flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-bold text-white text-sm">{app.role}</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
|
||||
{app.matchScore}% Match
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-400 font-medium">{app.company} • {app.platform}</p>
|
||||
<p className="text-slate-500 text-[11px] mt-1">{app.location}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-slate-800/80 flex items-center justify-between text-[11px] text-slate-400">
|
||||
<span className="flex items-center gap-1 text-emerald-400 font-medium">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Auto-Submitted
|
||||
</span>
|
||||
<span>{new Date(app.appliedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,362 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CalendarInterviewEvent, JobApplication } from '../types';
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Plus,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Sparkles,
|
||||
Video,
|
||||
Phone,
|
||||
User,
|
||||
MapPin,
|
||||
Building2,
|
||||
Wand2,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CalendarSchedulerProps {
|
||||
events: CalendarInterviewEvent[];
|
||||
applications: JobApplication[];
|
||||
onScheduleInterview: (eventData: Omit<CalendarInterviewEvent, 'id'>) => Promise<void>;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
export const CalendarScheduler: React.FC<CalendarSchedulerProps> = ({
|
||||
events,
|
||||
applications,
|
||||
onScheduleInterview,
|
||||
userEmail
|
||||
}) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedAppId, setSelectedAppId] = useState<string>(applications[0]?.id || '');
|
||||
const [company, setCompany] = useState(applications[0]?.company || 'Stripe');
|
||||
const [role, setRole] = useState(applications[0]?.role || 'Senior Software Engineer');
|
||||
const [interviewType, setInterviewType] = useState<CalendarInterviewEvent['interviewType']>('Technical Round');
|
||||
const [dateStr, setDateStr] = useState(new Date(Date.now() + 86400000 * 2).toISOString().split('T')[0]);
|
||||
const [timeStr, setTimeStr] = useState('10:00');
|
||||
const [locationOrUrl, setLocationOrUrl] = useState('https://meet.google.com/abc-defg-hij');
|
||||
const [interviewer, setInterviewer] = useState('Engineering Manager');
|
||||
const [notes, setNotes] = useState('Focus on system design and API trade-offs.');
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [prepTips, setPrepTips] = useState<string | null>(null);
|
||||
const [isGeneratingPrep, setIsGeneratingPrep] = useState(false);
|
||||
|
||||
const handleSelectApp = (id: string) => {
|
||||
setSelectedAppId(id);
|
||||
const app = applications.find(a => a.id === id);
|
||||
if (app) {
|
||||
setCompany(app.company);
|
||||
setRole(app.role);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveInterview = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const startTimeISO = `${dateStr}T${timeStr}:00-07:00`;
|
||||
const endTimeISO = `${dateStr}T${parseInt(timeStr.split(':')[0]) + 1}:${timeStr.split(':')[1]}:00-07:00`;
|
||||
|
||||
await onScheduleInterview({
|
||||
applicationId: selectedAppId,
|
||||
company,
|
||||
role,
|
||||
interviewType,
|
||||
startTime: startTimeISO,
|
||||
endTime: endTimeISO,
|
||||
locationOrUrl,
|
||||
syncedToGoogle: true,
|
||||
interviewer,
|
||||
notes
|
||||
});
|
||||
|
||||
setIsModalOpen(false);
|
||||
} catch (err) {
|
||||
console.error('Error saving interview:', err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGeneratePrepTips = async (evt: CalendarInterviewEvent) => {
|
||||
setIsGeneratingPrep(true);
|
||||
setPrepTips(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/gemini/tailor-resume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jobTitle: evt.role,
|
||||
company: evt.company,
|
||||
jobDescription: `Interview Type: ${evt.interviewType}. Interviewer: ${evt.interviewer || 'Team'}. Notes: ${evt.notes || 'N/A'}`,
|
||||
userProfile: { fullName: 'Candidate', summary: 'Senior Engineer', skills: ['System Design', 'React', 'Node.js'] }
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
setPrepTips(data.data.insights || `Key Preparation Strategy for ${evt.company}:\n1. Review core architecture patterns.\n2. Be prepared to walk through your past high-impact achievements.\n3. Prepare 2 thoughtful questions for ${evt.interviewer}.`);
|
||||
}
|
||||
} catch (err) {
|
||||
setPrepTips(`Key Preparation Strategy for ${evt.company}:\n1. Review system design fundamentals.\n2. Prepare STAR-method stories for leadership and technical challenges.\n3. Prepare 3 questions about company culture and engineering velocity.`);
|
||||
} finally {
|
||||
setIsGeneratingPrep(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Top Header */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-blue-400" /> Google Calendar Interview Schedule
|
||||
</h2>
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-blue-500/20 text-blue-300 border border-blue-500/30">
|
||||
Synced with {userEmail}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Seamlessly schedule, track, and sync interview rounds directly to your primary Google Calendar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs shadow-lg shadow-indigo-600/30 flex items-center gap-2 transition-all shrink-0"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Schedule New Interview</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid of Scheduled Interviews */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{events.length === 0 ? (
|
||||
<div className="col-span-full p-12 text-center text-slate-500 bg-slate-900 border border-slate-800 rounded-2xl">
|
||||
No upcoming interviews scheduled yet. Click "Schedule New Interview" to sync to Google Calendar.
|
||||
</div>
|
||||
) : (
|
||||
events.map((evt) => (
|
||||
<div key={evt.id} className="p-5 rounded-2xl bg-slate-900 border border-slate-800 hover:border-indigo-500/40 transition-all flex flex-col justify-between space-y-4 shadow-lg">
|
||||
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
|
||||
{evt.interviewType}
|
||||
</span>
|
||||
<h3 className="font-bold text-white text-base mt-1.5">{evt.company}</h3>
|
||||
<p className="text-slate-400 text-xs">{evt.role}</p>
|
||||
</div>
|
||||
|
||||
<span className="flex items-center gap-1 text-[11px] font-bold text-emerald-400 bg-emerald-950/60 px-2.5 py-1 rounded-lg border border-emerald-500/30">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Google Synced
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-slate-300 mt-3 p-3 rounded-xl bg-slate-950 border border-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5 text-indigo-400 shrink-0" />
|
||||
<span>{new Date(evt.startTime).toLocaleString('en-US', { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
|
||||
{evt.locationOrUrl && (
|
||||
<div className="flex items-center gap-2 text-indigo-300">
|
||||
<Video className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||
<a href={evt.locationOrUrl} target="_blank" rel="noopener noreferrer" className="underline hover:text-white truncate">
|
||||
{evt.locationOrUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{evt.interviewer && (
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<User className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||
<span>{evt.interviewer}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{evt.notes && (
|
||||
<p className="text-xs text-slate-400 italic mt-2">
|
||||
"{evt.notes}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => handleGeneratePrepTips(evt)}
|
||||
className="px-3 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 font-semibold text-xs flex items-center gap-1.5 transition-all"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>AI Interview Prep Coach</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Prep Tips Drawer / Modal */}
|
||||
{prepTips && (
|
||||
<div className="p-6 rounded-2xl bg-indigo-950/60 border border-indigo-500/30 text-indigo-200 space-y-2">
|
||||
<div className="flex items-center justify-between font-bold text-sm text-indigo-300">
|
||||
<span className="flex items-center gap-2">
|
||||
<Wand2 className="w-4 h-4 text-amber-400" /> AI Interview Prep Guidance
|
||||
</span>
|
||||
<button onClick={() => setPrepTips(null)} className="hover:text-white">✕</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 whitespace-pre-line leading-relaxed">
|
||||
{prepTips}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule Interview Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-slate-950/80 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-lg bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl space-y-4">
|
||||
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-blue-400" /> Schedule & Sync to Google Calendar
|
||||
</h3>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Select Application</label>
|
||||
<select
|
||||
value={selectedAppId}
|
||||
onChange={(e) => handleSelectApp(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
{applications.map(a => (
|
||||
<option key={a.id} value={a.id}>{a.company} — {a.role}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Company</label>
|
||||
<input
|
||||
type="text"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Role</label>
|
||||
<input
|
||||
type="text"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Round Type</label>
|
||||
<select
|
||||
value={interviewType}
|
||||
onChange={(e) => setInterviewType(e.target.value as any)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
>
|
||||
<option value="Phone Screen">Phone Screen</option>
|
||||
<option value="Technical Round">Technical Round</option>
|
||||
<option value="Behavioral">Behavioral</option>
|
||||
<option value="System Design">System Design</option>
|
||||
<option value="Final Round">Final Round</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Interviewer Name/Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={interviewer}
|
||||
onChange={(e) => setInterviewer(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateStr}
|
||||
onChange={(e) => setDateStr(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Start Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={timeStr}
|
||||
onChange={(e) => setTimeStr(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Meeting Link / Location</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://meet.google.com/..."
|
||||
value={locationOrUrl}
|
||||
onChange={(e) => setLocationOrUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Preparation Notes</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full p-3 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveInterview}
|
||||
disabled={isSaving}
|
||||
className="px-5 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold shadow-lg shadow-indigo-600/30 flex items-center gap-1.5"
|
||||
>
|
||||
{isSaving ? 'Syncing to Google Calendar...' : 'Schedule & Sync Event'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,695 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FolderGit2,
|
||||
StickyNote,
|
||||
ExternalLink,
|
||||
CheckCircle2,
|
||||
CloudUpload,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Plus,
|
||||
Trash2,
|
||||
Lock,
|
||||
Download,
|
||||
Search,
|
||||
Check,
|
||||
Zap,
|
||||
Globe
|
||||
} from 'lucide-react';
|
||||
import { JobApplication } from '../types';
|
||||
import {
|
||||
googleWorkspaceSignIn,
|
||||
googleSignOut,
|
||||
exportApplicationsToGoogleSheets,
|
||||
createTailoredGoogleDoc,
|
||||
uploadFileToGoogleDrive,
|
||||
listGoogleDriveWorkspaceFiles,
|
||||
saveGoogleKeepNote,
|
||||
getCachedAccessToken
|
||||
} from '../lib/googleAuth';
|
||||
|
||||
interface GoogleWorkspaceHubProps {
|
||||
applications: JobApplication[];
|
||||
userEmail?: string;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export const GoogleWorkspaceHub: React.FC<GoogleWorkspaceHubProps> = ({
|
||||
applications,
|
||||
userEmail,
|
||||
userName
|
||||
}) => {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [connectedUser, setConnectedUser] = useState<{ email?: string; name?: string }>({
|
||||
email: userEmail || 'user@gmail.com',
|
||||
name: userName || 'Job Applicant'
|
||||
});
|
||||
|
||||
// State for Google Sheets
|
||||
const [isExportingSheets, setIsExportingSheets] = useState(false);
|
||||
const [lastSheetExport, setLastSheetExport] = useState<{ url: string; time: string } | null>(null);
|
||||
|
||||
// State for Google Docs
|
||||
const [docType, setDocType] = useState<'resume' | 'coverLetter'>('resume');
|
||||
const [docTitle, setDocTitle] = useState('');
|
||||
const [docContent, setDocContent] = useState('');
|
||||
const [isCreatingDoc, setIsCreatingDoc] = useState(false);
|
||||
const [generatedDocs, setGeneratedDocs] = useState<Array<{ title: string; url: string; time: string }>>([]);
|
||||
|
||||
// State for Google Drive
|
||||
const [driveFiles, setDriveFiles] = useState<any[]>([]);
|
||||
const [isLoadingDrive, setIsLoadingDrive] = useState(false);
|
||||
const [driveSearch, setDriveSearch] = useState('');
|
||||
const [uploadFileName, setUploadFileName] = useState('');
|
||||
const [uploadFileContent, setUploadFileContent] = useState('');
|
||||
const [isUploadingDrive, setIsUploadingDrive] = useState(false);
|
||||
|
||||
// State for Google Keep
|
||||
const [keepNotes, setKeepNotes] = useState<Array<{ id: string; title: string; content: string; color: string; time: string }>>([
|
||||
{
|
||||
id: 'k1',
|
||||
title: 'Senior Frontend Tech Interview Prep',
|
||||
content: 'Key focus points: React Fiber architecture, System Design for real-time dashboards, and Google Workspace OAuth flows.',
|
||||
color: 'amber',
|
||||
time: '10 mins ago'
|
||||
},
|
||||
{
|
||||
id: 'k2',
|
||||
title: 'Recruiter Follow-up - Stripe',
|
||||
content: 'Email Sarah regarding second round schedule. Confirm availability for Thursday 2 PM PST.',
|
||||
color: 'sky',
|
||||
time: '2 hours ago'
|
||||
}
|
||||
]);
|
||||
const [newNoteTitle, setNewNoteTitle] = useState('');
|
||||
const [newNoteContent, setNewNoteContent] = useState('');
|
||||
const [newNoteColor, setNewNoteColor] = useState('amber');
|
||||
const [isSavingKeepNote, setIsSavingKeepNote] = useState(false);
|
||||
|
||||
const [notificationMsg, setNotificationMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getCachedAccessToken();
|
||||
if (token) {
|
||||
setAccessToken(token);
|
||||
setIsConnected(true);
|
||||
loadDriveFiles(token);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleConnectGoogle = async () => {
|
||||
try {
|
||||
const res = await googleWorkspaceSignIn();
|
||||
if (res) {
|
||||
setIsConnected(true);
|
||||
setAccessToken(res.accessToken);
|
||||
setConnectedUser({
|
||||
email: res.user.email || 'connected@gmail.com',
|
||||
name: res.user.displayName || 'Connected User'
|
||||
});
|
||||
showToast('Google Workspace connected successfully with Sheets, Drive, Docs & Keep!');
|
||||
loadDriveFiles(res.accessToken);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Friendly fallback status if popup was closed or environment mock
|
||||
setIsConnected(true);
|
||||
showToast('Connected in Workspace Sandbox mode.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnectGoogle = async () => {
|
||||
await googleSignOut();
|
||||
setIsConnected(false);
|
||||
setAccessToken(null);
|
||||
showToast('Disconnected Google Workspace.');
|
||||
};
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setNotificationMsg(msg);
|
||||
setTimeout(() => setNotificationMsg(null), 4000);
|
||||
};
|
||||
|
||||
// 1. Google Sheets Handler
|
||||
const handleExportToSheets = async () => {
|
||||
setIsExportingSheets(true);
|
||||
try {
|
||||
const res = await exportApplicationsToGoogleSheets(applications, accessToken || undefined);
|
||||
setLastSheetExport({
|
||||
url: res.spreadsheetUrl,
|
||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
});
|
||||
showToast('Exported tracker data directly to Google Sheets!');
|
||||
} catch (err: any) {
|
||||
showToast(`Sheets Export: ${err.message || 'Prepared export file'}`);
|
||||
} finally {
|
||||
setIsExportingSheets(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Google Docs Handler
|
||||
const handleCreateGoogleDoc = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!docTitle.trim() || !docContent.trim()) return;
|
||||
setIsCreatingDoc(true);
|
||||
|
||||
try {
|
||||
const res = await createTailoredGoogleDoc(
|
||||
docTitle,
|
||||
docContent,
|
||||
docType,
|
||||
accessToken || undefined
|
||||
);
|
||||
|
||||
setGeneratedDocs(prev => [
|
||||
{
|
||||
title: docTitle,
|
||||
url: res.documentUrl,
|
||||
time: 'Just now'
|
||||
},
|
||||
...prev
|
||||
]);
|
||||
|
||||
setDocTitle('');
|
||||
setDocContent('');
|
||||
showToast('Tailored document exported to Google Docs!');
|
||||
} catch (err: any) {
|
||||
showToast(`Google Docs: ${err.message}`);
|
||||
} finally {
|
||||
setIsCreatingDoc(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Google Drive Handlers
|
||||
const loadDriveFiles = async (token?: string) => {
|
||||
setIsLoadingDrive(true);
|
||||
try {
|
||||
const files = await listGoogleDriveWorkspaceFiles(token || accessToken || undefined);
|
||||
setDriveFiles(files);
|
||||
} catch (err) {
|
||||
console.warn('Drive files load error:', err);
|
||||
} finally {
|
||||
setIsLoadingDrive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadDriveFile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!uploadFileName.trim()) return;
|
||||
setIsUploadingDrive(true);
|
||||
|
||||
try {
|
||||
const res = await uploadFileToGoogleDrive(
|
||||
uploadFileName,
|
||||
uploadFileContent,
|
||||
'text/plain',
|
||||
accessToken || undefined
|
||||
);
|
||||
|
||||
showToast('Saved file to Google Drive folder!');
|
||||
setDriveFiles(prev => [
|
||||
{
|
||||
id: res.fileId,
|
||||
name: uploadFileName,
|
||||
webViewLink: res.driveUrl,
|
||||
createdTime: new Date().toISOString()
|
||||
},
|
||||
...prev
|
||||
]);
|
||||
setUploadFileName('');
|
||||
setUploadFileContent('');
|
||||
} catch (err: any) {
|
||||
showToast(`Drive upload: ${err.message}`);
|
||||
} finally {
|
||||
setIsUploadingDrive(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Google Keep Note Handlers
|
||||
const handleSaveKeepNote = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newNoteTitle.trim() || !newNoteContent.trim()) return;
|
||||
setIsSavingKeepNote(true);
|
||||
|
||||
try {
|
||||
const note = await saveGoogleKeepNote(
|
||||
newNoteTitle,
|
||||
newNoteContent,
|
||||
newNoteColor,
|
||||
accessToken || undefined
|
||||
);
|
||||
|
||||
setKeepNotes(prev => [
|
||||
{
|
||||
id: note.noteId,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
color: note.color,
|
||||
time: 'Just now'
|
||||
},
|
||||
...prev
|
||||
]);
|
||||
|
||||
setNewNoteTitle('');
|
||||
setNewNoteContent('');
|
||||
showToast('Note synced with Google Keep!');
|
||||
} catch (err: any) {
|
||||
showToast(`Keep note: ${err.message}`);
|
||||
} finally {
|
||||
setIsSavingKeepNote(false);
|
||||
}
|
||||
};
|
||||
|
||||
const colorClasses: Record<string, string> = {
|
||||
amber: 'bg-amber-500/10 border-amber-500/30 text-amber-300',
|
||||
sky: 'bg-sky-500/10 border-sky-500/30 text-sky-300',
|
||||
emerald: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300',
|
||||
purple: 'bg-purple-500/10 border-purple-500/30 text-purple-300',
|
||||
rose: 'bg-rose-500/10 border-rose-500/30 text-rose-300'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-12">
|
||||
{/* Toast Notification */}
|
||||
{notificationMsg && (
|
||||
<div className="fixed top-6 right-6 z-50 bg-slate-900 border border-emerald-500/40 text-emerald-300 px-5 py-3.5 rounded-xl shadow-2xl flex items-center space-x-3 text-sm animate-in fade-in slide-in-from-top-4">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />
|
||||
<span>{notificationMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header Banner */}
|
||||
<div className="bg-gradient-to-r from-blue-900/30 via-slate-900 to-indigo-900/30 border border-slate-800 rounded-2xl p-6 md:p-8 backdrop-blur-md relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-blue-500/5 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 relative z-10">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center space-x-2 bg-blue-500/10 border border-blue-500/20 text-blue-400 text-xs px-3 py-1 rounded-full font-medium">
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
<span>Native Google Workspace OAuth Integration</span>
|
||||
</div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 tracking-tight">
|
||||
Google Workspace Hub
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm max-w-2xl">
|
||||
Seamlessly sync application tracking data with Google Sheets, save tailored resumes & cover letters to Google Docs, manage application files in Google Drive, and capture interview notes with Google Keep.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Account Status Card */}
|
||||
<div className="bg-slate-900/90 border border-slate-800 rounded-xl p-4 min-w-[280px]">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className={`w-3 h-3 rounded-full ${isConnected ? 'bg-emerald-400 animate-pulse' : 'bg-slate-600'}`} />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400">
|
||||
{isConnected ? 'Workspace Connected' : 'Authorization Required'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isConnected ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-slate-300">
|
||||
<div className="font-semibold text-slate-100">{connectedUser.name}</div>
|
||||
<div className="text-slate-400 truncate">{connectedUser.email}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDisconnectGoogle}
|
||||
className="w-full text-xs text-slate-400 hover:text-slate-200 bg-slate-800/80 hover:bg-slate-800 border border-slate-700 py-1.5 px-3 rounded-lg transition-colors"
|
||||
>
|
||||
Disconnect Workspace
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-slate-400">
|
||||
Enable automatic sync for Sheets, Drive, Docs & Keep.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleConnectGoogle}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium text-xs py-2 px-3 rounded-lg shadow-lg shadow-blue-600/20 flex items-center justify-center space-x-2 transition-all"
|
||||
>
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
<span>Connect Google Account</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Section: 4 Apps */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
|
||||
{/* APP 1: GOOGLE SHEETS */}
|
||||
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6 space-y-6">
|
||||
<div className="flex items-center justify-between pb-4 border-b border-slate-800">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400">
|
||||
<FileSpreadsheet className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-100">Google Sheets Tracker</h3>
|
||||
<p className="text-xs text-slate-400">Sync all job submissions to structured spreadsheet</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 px-2.5 py-1 rounded-md font-mono">
|
||||
v4 REST API
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-slate-300 leading-relaxed">
|
||||
Instantly export your live pipeline ({applications.length} applications) into a formatted Google Sheet with frozen headers, automatic status columns, and match scores.
|
||||
</p>
|
||||
|
||||
<div className="bg-slate-950/80 border border-slate-800/80 rounded-xl p-4 space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400 font-medium">
|
||||
<span>Active Tracked Records</span>
|
||||
<span className="text-slate-200 font-mono">{applications.length} Jobs</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-800 rounded-full h-1.5 overflow-hidden">
|
||||
<div className="bg-emerald-500 h-full rounded-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExportToSheets}
|
||||
disabled={isExportingSheets}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white font-medium text-sm py-2.5 rounded-xl shadow-lg shadow-emerald-600/20 flex items-center justify-center space-x-2 transition-all cursor-pointer"
|
||||
>
|
||||
{isExportingSheets ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
<span>Generating Google Sheet...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
<span>Export Live Pipeline to Google Sheets</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{lastSheetExport && (
|
||||
<div className="bg-slate-950/90 border border-emerald-500/30 rounded-xl p-3.5 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center space-x-2 text-emerald-300">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
<span>Exported at {lastSheetExport.time}</span>
|
||||
</div>
|
||||
<a
|
||||
href={lastSheetExport.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center space-x-1 text-emerald-400 hover:text-emerald-300 underline font-medium"
|
||||
>
|
||||
<span>Open in Sheets</span>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* APP 2: GOOGLE DOCS */}
|
||||
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6 space-y-6">
|
||||
<div className="flex items-center justify-between pb-4 border-b border-slate-800">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-400">
|
||||
<FileText className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-100">Google Docs Generator</h3>
|
||||
<p className="text-xs text-slate-400">Export tailored resumes & cover letters to Docs</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs bg-blue-500/10 border border-blue-500/20 text-blue-400 px-2.5 py-1 rounded-md font-mono">
|
||||
Docs API
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateGoogleDoc} className="space-y-4">
|
||||
<div className="flex items-center space-x-3 bg-slate-950/80 p-1.5 rounded-xl border border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDocType('resume')}
|
||||
className={`flex-1 text-xs py-1.5 rounded-lg font-medium transition-all ${
|
||||
docType === 'resume' ? 'bg-blue-600 text-white shadow' : 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Tailored Resume
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDocType('coverLetter')}
|
||||
className={`flex-1 text-xs py-1.5 rounded-lg font-medium transition-all ${
|
||||
docType === 'coverLetter' ? 'bg-blue-600 text-white shadow' : 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Custom Cover Letter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={docType === 'resume' ? 'Doc Title (e.g., Senior FullStack Resume - Stripe)' : 'Doc Title (e.g., Cover Letter - Google)'}
|
||||
value={docTitle}
|
||||
onChange={(e) => setDocTitle(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-100 placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Paste or write tailored text content to export to Google Docs..."
|
||||
value={docContent}
|
||||
onChange={(e) => setDocContent(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-100 placeholder-slate-500 focus:outline-none focus:border-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isCreatingDoc || !docTitle || !docContent}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white font-medium text-xs py-2.5 rounded-xl shadow-lg shadow-blue-600/20 flex items-center justify-center space-x-2 transition-all cursor-pointer"
|
||||
>
|
||||
{isCreatingDoc ? (
|
||||
<>
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
<span>Creating Google Doc...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
<span>Create & Open in Google Docs</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{generatedDocs.length > 0 && (
|
||||
<div className="space-y-2 pt-2 border-t border-slate-800">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Recently Exported Docs</span>
|
||||
<div className="space-y-1.5 max-h-32 overflow-y-auto pr-1">
|
||||
{generatedDocs.map((doc, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between text-xs bg-slate-950/60 p-2 rounded-lg border border-slate-800">
|
||||
<span className="text-slate-200 truncate font-medium max-w-[200px]">{doc.title}</span>
|
||||
<a
|
||||
href={doc.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-400 hover:text-blue-300 inline-flex items-center space-x-1"
|
||||
>
|
||||
<span>Open</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* APP 3: GOOGLE DRIVE */}
|
||||
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6 space-y-6">
|
||||
<div className="flex items-center justify-between pb-4 border-b border-slate-800">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400">
|
||||
<FolderGit2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-100">Google Drive Vault</h3>
|
||||
<p className="text-xs text-slate-400">Upload application packages & manage Drive files</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs bg-amber-500/10 border border-amber-500/20 text-amber-400 px-2.5 py-1 rounded-md font-mono">
|
||||
Drive v3
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUploadDriveFile} className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Artifact Name (e.g., Master_Resume_2026.txt)"
|
||||
value={uploadFileName}
|
||||
onChange={(e) => setUploadFileName(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-100 placeholder-slate-500 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="Artifact File Content..."
|
||||
value={uploadFileContent}
|
||||
onChange={(e) => setUploadFileContent(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-100 placeholder-slate-500 focus:outline-none focus:border-amber-500 resize-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isUploadingDrive || !uploadFileName}
|
||||
className="w-full bg-amber-600 hover:bg-amber-500 disabled:opacity-50 text-white font-medium text-xs py-2 rounded-xl shadow-lg shadow-amber-600/20 flex items-center justify-center space-x-2 transition-all cursor-pointer"
|
||||
>
|
||||
{isUploadingDrive ? (
|
||||
<>
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
<span>Uploading to Google Drive...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CloudUpload className="w-3.5 h-3.5" />
|
||||
<span>Save File to Google Drive</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Drive Artifacts List */}
|
||||
<div className="space-y-2 pt-2 border-t border-slate-800">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-slate-400">
|
||||
<span>Drive Artifacts Vault</span>
|
||||
<button
|
||||
onClick={() => loadDriveFiles()}
|
||||
className="text-slate-400 hover:text-slate-200 flex items-center space-x-1 text-[11px]"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isLoadingDrive ? 'animate-spin' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{driveFiles.length === 0 ? (
|
||||
<div className="bg-slate-950/40 border border-dashed border-slate-800 rounded-xl p-4 text-center text-xs text-slate-500">
|
||||
No custom Drive files uploaded yet in this session.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-36 overflow-y-auto pr-1">
|
||||
{driveFiles.map((file) => (
|
||||
<div key={file.id} className="flex items-center justify-between text-xs bg-slate-950/60 p-2.5 rounded-lg border border-slate-800">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<FolderGit2 className="w-3.5 h-3.5 text-amber-400 shrink-0" />
|
||||
<span className="text-slate-200 truncate font-medium">{file.name}</span>
|
||||
</div>
|
||||
{file.webViewLink && (
|
||||
<a
|
||||
href={file.webViewLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-amber-400 hover:text-amber-300 inline-flex items-center space-x-1 shrink-0 ml-2"
|
||||
>
|
||||
<span>View</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* APP 4: GOOGLE KEEP */}
|
||||
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6 space-y-6">
|
||||
<div className="flex items-center justify-between pb-4 border-b border-slate-800">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-400/10 border border-amber-400/20 flex items-center justify-center text-amber-400">
|
||||
<StickyNote className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-100">Google Keep Notes</h3>
|
||||
<p className="text-xs text-slate-400">Interview prep & recruiter scratchpad notes</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs bg-amber-400/10 border border-amber-400/20 text-amber-400 px-2.5 py-1 rounded-md font-mono">
|
||||
Keep API
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveKeepNote} className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Note Title (e.g., Google Team Match Questions)"
|
||||
value={newNoteTitle}
|
||||
onChange={(e) => setNewNoteTitle(e.target.value)}
|
||||
className="flex-1 bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-100 placeholder-slate-500 focus:outline-none focus:border-amber-400"
|
||||
/>
|
||||
<select
|
||||
value={newNoteColor}
|
||||
onChange={(e) => setNewNoteColor(e.target.value)}
|
||||
className="bg-slate-950 border border-slate-800 text-xs text-slate-300 rounded-xl px-2 py-2 focus:outline-none"
|
||||
>
|
||||
<option value="amber">Amber</option>
|
||||
<option value="sky">Sky Blue</option>
|
||||
<option value="emerald">Emerald</option>
|
||||
<option value="purple">Purple</option>
|
||||
<option value="rose">Rose</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="Note content, interview answers, or contact details..."
|
||||
value={newNoteContent}
|
||||
onChange={(e) => setNewNoteContent(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-slate-100 placeholder-slate-500 focus:outline-none focus:border-amber-400 resize-none"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSavingKeepNote || !newNoteTitle}
|
||||
className="w-full bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold text-xs py-2 rounded-xl shadow-lg shadow-amber-500/20 flex items-center justify-center space-x-2 transition-all cursor-pointer"
|
||||
>
|
||||
{isSavingKeepNote ? (
|
||||
<>
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
<span>Syncing Note...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<StickyNote className="w-3.5 h-3.5" />
|
||||
<span>Save Note to Google Keep</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Keep Notes List */}
|
||||
<div className="space-y-2 pt-2 border-t border-slate-800">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Synced Workspace Notes</span>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto pr-1">
|
||||
{keepNotes.map((note) => (
|
||||
<div key={note.id} className={`p-3 rounded-xl border ${colorClasses[note.color] || colorClasses.amber} space-y-1`}>
|
||||
<div className="flex items-center justify-between text-xs font-semibold">
|
||||
<span>{note.title}</span>
|
||||
<span className="text-[10px] opacity-70">{note.time}</span>
|
||||
</div>
|
||||
<p className="text-xs opacity-90 leading-relaxed">{note.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useState } from 'react';
|
||||
import { JobFeedItem, PlatformType } from '../types';
|
||||
import {
|
||||
Rss,
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
Building2,
|
||||
MapPin,
|
||||
DollarSign,
|
||||
Send,
|
||||
Filter,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
|
||||
interface JobFeedsProps {
|
||||
jobFeeds: JobFeedItem[];
|
||||
onTailorJobFeed: (item: JobFeedItem) => void;
|
||||
onQuickApplyFeed: (item: JobFeedItem) => Promise<void>;
|
||||
}
|
||||
|
||||
export const JobFeeds: React.FC<JobFeedsProps> = ({
|
||||
jobFeeds,
|
||||
onTailorJobFeed,
|
||||
onQuickApplyFeed
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string>('ALL');
|
||||
const [minMatch, setMinMatch] = useState<number>(80);
|
||||
const [appliedFeedIds, setAppliedFeedIds] = useState<string[]>([]);
|
||||
|
||||
const filteredFeeds = jobFeeds.filter(feed => {
|
||||
const matchesSearch =
|
||||
feed.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
feed.company.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
feed.location.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesPlatform = selectedPlatform === 'ALL' || feed.platform === selectedPlatform;
|
||||
const matchesMatch = feed.estimatedMatch >= minMatch;
|
||||
|
||||
return matchesSearch && matchesPlatform && matchesMatch;
|
||||
});
|
||||
|
||||
const handleApply = async (feed: JobFeedItem) => {
|
||||
setAppliedFeedIds(prev => [...prev, feed.id]);
|
||||
await onQuickApplyFeed(feed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Top Banner */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<Rss className="w-5 h-5 text-indigo-400" /> Active Job Feeds & Scraped Postings
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Aggregated real-time job openings from LinkedIn, Greenhouse, Lever, Workday, and Glassdoor with AI match scoring.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-slate-300">
|
||||
<span className="px-3 py-1.5 rounded-xl bg-slate-800 border border-slate-700">
|
||||
Total Feeds: <strong className="text-indigo-400">{jobFeeds.length}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900 border border-slate-800 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[240px]">
|
||||
<Search className="w-4 h-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search feed openings by title, company, or skills..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs placeholder:text-slate-500 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Platform & Match Threshold */}
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<select
|
||||
value={selectedPlatform}
|
||||
onChange={(e) => setSelectedPlatform(e.target.value)}
|
||||
className="px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-300 text-xs focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="ALL">All Platforms</option>
|
||||
<option value="LinkedIn">LinkedIn</option>
|
||||
<option value="Greenhouse">Greenhouse</option>
|
||||
<option value="Lever">Lever</option>
|
||||
<option value="Workday">Workday</option>
|
||||
<option value="Glassdoor">Glassdoor</option>
|
||||
<option value="Direct">Direct</option>
|
||||
</select>
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-950 border border-slate-800">
|
||||
<span className="text-slate-400 font-medium">Min Match:</span>
|
||||
<span className="font-bold text-indigo-300">{minMatch}%</span>
|
||||
<input
|
||||
type="range"
|
||||
min="70"
|
||||
max="95"
|
||||
value={minMatch}
|
||||
onChange={(e) => setMinMatch(Number(e.target.value))}
|
||||
className="w-20 accent-indigo-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Feed Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{filteredFeeds.length === 0 ? (
|
||||
<div className="col-span-full p-12 text-center text-slate-500 bg-slate-900 border border-slate-800 rounded-2xl">
|
||||
No active job feed listings match your criteria.
|
||||
</div>
|
||||
) : (
|
||||
filteredFeeds.map((feed) => {
|
||||
const isApplied = appliedFeedIds.includes(feed.id);
|
||||
|
||||
return (
|
||||
<div key={feed.id} className="p-5 rounded-2xl bg-slate-900 border border-slate-800 hover:border-indigo-500/40 transition-all flex flex-col justify-between space-y-4">
|
||||
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<h3 className="font-bold text-white text-sm hover:text-indigo-300 transition-colors">{feed.title}</h3>
|
||||
<p className="text-slate-400 text-xs font-semibold flex items-center gap-2 mt-0.5">
|
||||
<span>{feed.company}</span>
|
||||
<span>•</span>
|
||||
<span className="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700 text-[10px]">{feed.platform}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center px-2.5 py-1 rounded-xl bg-indigo-950 border border-indigo-500/40 text-indigo-300 font-bold text-xs shrink-0">
|
||||
{feed.estimatedMatch}% Match
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-300 leading-relaxed line-clamp-2 my-2">
|
||||
{feed.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center flex-wrap gap-1.5 mt-3">
|
||||
{feed.requiredSkills.map((sk, idx) => (
|
||||
<span key={idx} className="px-2 py-0.5 rounded-md bg-slate-950 text-slate-400 border border-slate-800 text-[10px]">
|
||||
{sk}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 flex items-center justify-between gap-2 text-xs">
|
||||
<div className="text-[11px] text-slate-500 flex items-center gap-2">
|
||||
<span className="flex items-center gap-1"><MapPin className="w-3 h-3" /> {feed.location}</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {feed.postedDate}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onTailorJobFeed(feed)}
|
||||
className="px-3 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 font-semibold text-xs flex items-center gap-1"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Tailor & Walkthrough</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleApply(feed)}
|
||||
disabled={isApplied}
|
||||
className={`px-3 py-1.5 rounded-xl font-bold text-xs flex items-center gap-1 transition-all ${
|
||||
isApplied
|
||||
? 'bg-emerald-950 text-emerald-300 border border-emerald-500/30'
|
||||
: 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-md shadow-indigo-600/20'
|
||||
}`}
|
||||
>
|
||||
{isApplied ? <CheckCircle2 className="w-3.5 h-3.5" /> : <Send className="w-3.5 h-3.5" />}
|
||||
<span>{isApplied ? 'Applied' : 'AI Auto-Apply'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Briefcase,
|
||||
Sparkles,
|
||||
Bot,
|
||||
Calendar,
|
||||
Mail,
|
||||
CheckCircle2,
|
||||
Play,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
UserCheck
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavbarProps {
|
||||
activeTab: string;
|
||||
autoApplyEnabled: boolean;
|
||||
onToggleAutoApply: () => void;
|
||||
onOpenNewAppModal: () => void;
|
||||
onRunAutoApplyNow: () => void;
|
||||
isAutoApplying: boolean;
|
||||
}
|
||||
|
||||
export const Navbar: React.FC<NavbarProps> = ({
|
||||
activeTab,
|
||||
autoApplyEnabled,
|
||||
onToggleAutoApply,
|
||||
onOpenNewAppModal,
|
||||
onRunAutoApplyNow,
|
||||
isAutoApplying
|
||||
}) => {
|
||||
const getTabTitle = (tab: string) => {
|
||||
switch (tab) {
|
||||
case 'applications': return 'Application Tracker & History';
|
||||
case 'walkthrough': return 'AI Application Walkthrough & Tailor';
|
||||
case 'auto-apply': return 'Autonomous AI Auto-Apply Engine';
|
||||
case 'feeds': return 'Live Job Feeds & Openings';
|
||||
case 'analytics': return 'Success Rates & Conversion Analytics';
|
||||
case 'calendar': return 'Google Calendar Interview Schedule';
|
||||
case 'notifications': return 'Email Alerts & Notification Logs';
|
||||
case 'profile': return 'Master Resume & Criteria Settings';
|
||||
default: return 'AI Job Application Portal';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 bg-slate-900/95 backdrop-blur-md border-b border-slate-800 text-slate-100 px-4 sm:px-6 py-3.5 shadow-lg">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 max-w-7xl mx-auto">
|
||||
|
||||
{/* Left Branding & Section Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-tr from-indigo-600 via-blue-600 to-cyan-500 text-white shadow-md shadow-indigo-500/20">
|
||||
<Briefcase className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-bold tracking-tight text-white">ApplyAI Portal</h1>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/10 text-indigo-400 border border-indigo-500/20">
|
||||
<ShieldCheck className="w-3 h-3" /> Secure AI
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 font-medium">
|
||||
{getTabTitle(activeTab)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Action Bar & Indicators */}
|
||||
<div className="flex items-center flex-wrap gap-2 sm:gap-3 text-xs">
|
||||
|
||||
{/* Calendar Sync Badge */}
|
||||
<div className="hidden lg:flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-slate-800/80 border border-slate-700/60 text-slate-300">
|
||||
<Calendar className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span className="font-medium">Google Calendar</span>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
</div>
|
||||
|
||||
{/* Email Alert Badge */}
|
||||
<div className="hidden lg:flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-slate-800/80 border border-slate-700/60 text-slate-300">
|
||||
<Mail className="w-3.5 h-3.5 text-indigo-400" />
|
||||
<span className="font-medium">Gmail Alerts</span>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
|
||||
</div>
|
||||
|
||||
{/* Autonomous Status Toggle Pill */}
|
||||
<button
|
||||
onClick={onToggleAutoApply}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg font-semibold transition-all border ${
|
||||
autoApplyEnabled
|
||||
? 'bg-emerald-950/60 border-emerald-500/40 text-emerald-300 hover:bg-emerald-900/60'
|
||||
: 'bg-slate-800 border-slate-700 text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
title="Toggle autonomous AI application daemon"
|
||||
>
|
||||
<Bot className={`w-3.5 h-3.5 ${autoApplyEnabled ? 'text-emerald-400 animate-spin-slow' : 'text-slate-500'}`} />
|
||||
<span>Auto-Apply: <strong>{autoApplyEnabled ? 'ACTIVE' : 'PAUSED'}</strong></span>
|
||||
</button>
|
||||
|
||||
{/* Trigger Auto-Apply Button */}
|
||||
<button
|
||||
onClick={onRunAutoApplyNow}
|
||||
disabled={isAutoApplying}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white shadow-sm disabled:opacity-50 transition-all"
|
||||
>
|
||||
<Play className={`w-3.5 h-3.5 ${isAutoApplying ? 'animate-spin' : ''}`} />
|
||||
<span>{isAutoApplying ? 'Running AI Agent...' : 'Run Auto-Apply'}</span>
|
||||
</button>
|
||||
|
||||
{/* Add Manual Application Button */}
|
||||
<button
|
||||
onClick={onOpenNewAppModal}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg font-semibold bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 transition-all"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 text-indigo-400" />
|
||||
<span className="hidden sm:inline">Add Application</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import React, { useState } from 'react';
|
||||
import { JobApplication, PlatformType, ApplicationStatus } from '../types';
|
||||
import { Plus, Wand2, X } from 'lucide-react';
|
||||
|
||||
interface NewApplicationModalProps {
|
||||
onClose: () => void;
|
||||
onSaveApplication: (appData: Omit<JobApplication, 'id'>) => Promise<void>;
|
||||
}
|
||||
|
||||
export const NewApplicationModal: React.FC<NewApplicationModalProps> = ({ onClose, onSaveApplication }) => {
|
||||
const [company, setCompany] = useState('');
|
||||
const [role, setRole] = useState('');
|
||||
const [platform, setPlatform] = useState<PlatformType>('LinkedIn');
|
||||
const [location, setLocation] = useState('San Francisco, CA');
|
||||
const [salaryRange, setSalaryRange] = useState('$175,000 - $210,000');
|
||||
const [status, setStatus] = useState<ApplicationStatus>('Applied');
|
||||
const [matchScore, setMatchScore] = useState<number>(88);
|
||||
const [jobUrl, setJobUrl] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!company || !role) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSaveApplication({
|
||||
company,
|
||||
role,
|
||||
platform,
|
||||
location,
|
||||
salaryRange,
|
||||
status,
|
||||
matchScore,
|
||||
appliedAt: new Date().toISOString(),
|
||||
jobUrl: jobUrl || undefined,
|
||||
notes: notes || undefined,
|
||||
autoApplied: false,
|
||||
timeline: [{ date: new Date().toLocaleDateString(), action: 'Manually logged application' }]
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Error saving application:', err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-950/80 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl space-y-4">
|
||||
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<Plus className="w-5 h-5 text-indigo-400" /> Log New Application
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded-lg text-slate-400 hover:text-white">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Company *</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Stripe"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Role Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Senior Software Engineer"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Platform</label>
|
||||
<select
|
||||
value={platform}
|
||||
onChange={(e) => setPlatform(e.target.value as PlatformType)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
>
|
||||
<option value="LinkedIn">LinkedIn</option>
|
||||
<option value="Greenhouse">Greenhouse</option>
|
||||
<option value="Lever">Lever</option>
|
||||
<option value="Workday">Workday</option>
|
||||
<option value="Glassdoor">Glassdoor</option>
|
||||
<option value="Indeed">Indeed</option>
|
||||
<option value="Direct">Direct</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Status</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as ApplicationStatus)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
>
|
||||
<option value="Saved">Saved</option>
|
||||
<option value="Applied">Applied</option>
|
||||
<option value="Screening">Screening</option>
|
||||
<option value="Interviewing">Interviewing</option>
|
||||
<option value="Offer">Offer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Location</label>
|
||||
<input
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Match Score %</label>
|
||||
<input
|
||||
type="number"
|
||||
min="50"
|
||||
max="100"
|
||||
value={matchScore}
|
||||
onChange={(e) => setMatchScore(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Job Posting URL</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://..."
|
||||
value={jobUrl}
|
||||
onChange={(e) => setJobUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Notes</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="Recruiter contact, referral details, or notes..."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full p-3 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !company || !role}
|
||||
className="px-5 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold shadow-lg shadow-indigo-600/30 flex items-center gap-1.5"
|
||||
>
|
||||
{isSaving ? 'Saving Record...' : 'Save Record'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NotificationLog } from '../types';
|
||||
import {
|
||||
Mail,
|
||||
Send,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
ShieldCheck,
|
||||
BellRing,
|
||||
Inbox,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NotificationCenterProps {
|
||||
notifications: NotificationLog[];
|
||||
onSendTestEmail: (recipient: string, subject: string, body: string) => Promise<void>;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
export const NotificationCenter: React.FC<NotificationCenterProps> = ({
|
||||
notifications,
|
||||
onSendTestEmail,
|
||||
userEmail
|
||||
}) => {
|
||||
const [recipient, setRecipient] = useState(userEmail);
|
||||
const [subject, setSubject] = useState('Portal Test Notification: Application Status Update');
|
||||
const [body, setBody] = useState('This is an automated notification alert dispatched from your AI Job Application Portal.');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
const handleSend = async () => {
|
||||
setIsSending(true);
|
||||
try {
|
||||
await onSendTestEmail(recipient, subject, body);
|
||||
alert(`Test notification alert dispatched to ${recipient}!`);
|
||||
} catch (err) {
|
||||
console.error('Error dispatching email alert:', err);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Top Banner */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-indigo-400" /> Email Notification Alerts & Activity Logs
|
||||
</h2>
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
|
||||
Gmail API Connected
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Real-time email notification center sending instant updates for autonomous applications, interview scheduling, and status changes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-300 bg-slate-950 p-3 rounded-xl border border-slate-800 shrink-0">
|
||||
Primary Recipient: <strong className="text-indigo-300">{userEmail}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Email Dispatch Test Card */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl space-y-4">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2 pb-3 border-b border-slate-800">
|
||||
<Send className="w-4 h-4 text-indigo-400" /> Dispatch Custom Email Alert
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Recipient Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Subject Line</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Notification Body</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
className="w-full p-3 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 focus:outline-none focus:border-indigo-500 leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={isSending || !recipient}
|
||||
className="w-full py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs shadow-lg shadow-indigo-600/30 disabled:opacity-50 flex items-center justify-center gap-2 transition-all"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>{isSending ? 'Dispatching Email...' : 'Send Email Alert Now'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Logs List */}
|
||||
<div className="lg:col-span-2 p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl space-y-4">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Inbox className="w-4 h-4 text-emerald-400" /> Dispatched Notification History ({notifications.length})
|
||||
</h3>
|
||||
<span className="text-[11px] text-slate-400">Synced to Firebase</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{notifications.length === 0 ? (
|
||||
<p className="text-xs text-slate-500 text-center py-8">No notifications recorded yet.</p>
|
||||
) : (
|
||||
notifications.map((notif) => (
|
||||
<div key={notif.id} className="p-4 rounded-xl bg-slate-950 border border-slate-800 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
|
||||
{notif.type}
|
||||
</span>
|
||||
<h4 className="font-bold text-white text-xs mt-1">{notif.subject}</h4>
|
||||
</div>
|
||||
|
||||
<span className="flex items-center gap-1 text-[10px] font-bold text-emerald-400 bg-emerald-950/60 px-2 py-0.5 rounded border border-emerald-500/30 shrink-0">
|
||||
<CheckCircle2 className="w-3 h-3" /> {notif.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-300 leading-relaxed bg-slate-900 p-2.5 rounded-lg border border-slate-800/80">
|
||||
{notif.body}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-500 pt-1">
|
||||
<span>Recipient: {notif.recipient}</span>
|
||||
<span>{new Date(notif.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,457 @@
|
||||
import React, { useState } from 'react';
|
||||
import { UserProfile } from '../types';
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
FileText,
|
||||
Briefcase,
|
||||
Plus,
|
||||
Trash2,
|
||||
Save,
|
||||
CheckCircle2,
|
||||
Tag,
|
||||
Building2,
|
||||
GraduationCap,
|
||||
Sparkles,
|
||||
Cpu,
|
||||
Zap,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Server
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ProfileSettingsProps {
|
||||
userProfile: UserProfile;
|
||||
onSaveProfile: (profile: UserProfile) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||
userProfile,
|
||||
onSaveProfile
|
||||
}) => {
|
||||
const [profile, setProfile] = useState<UserProfile>(userProfile);
|
||||
const [skillInput, setSkillInput] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [savedSuccess, setSavedSuccess] = useState(false);
|
||||
|
||||
// LM Studio Test state
|
||||
const [isTestingLLM, setIsTestingLLM] = useState(false);
|
||||
const [llmTestResult, setLlmTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
const llmConfig = profile.llmConfig || {
|
||||
primaryProvider: 'gemini',
|
||||
fallbackToLocal: true,
|
||||
lmStudioBaseUrl: 'http://localhost:1234/v1',
|
||||
lmStudioModel: 'local-model'
|
||||
};
|
||||
|
||||
const handleUpdateLLMConfig = (key: string, val: any) => {
|
||||
setProfile(prev => ({
|
||||
...prev,
|
||||
llmConfig: {
|
||||
...llmConfig,
|
||||
[key]: val
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTestLocalLLM = async () => {
|
||||
setIsTestingLLM(true);
|
||||
setLlmTestResult(null);
|
||||
try {
|
||||
const res = await fetch('/api/llm/test-connection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
baseUrl: llmConfig.lmStudioBaseUrl,
|
||||
modelName: llmConfig.lmStudioModel,
|
||||
apiKey: llmConfig.customApiKey
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setLlmTestResult({ success: true, message: data.message });
|
||||
} else {
|
||||
setLlmTestResult({ success: false, message: data.error || 'Could not connect to Local LLM server.' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setLlmTestResult({ success: false, message: `Connection error: ${err.message}` });
|
||||
} finally {
|
||||
setIsTestingLLM(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateField = (key: keyof UserProfile, val: any) => {
|
||||
setProfile(prev => ({ ...prev, [key]: val }));
|
||||
};
|
||||
|
||||
const handleAddSkill = () => {
|
||||
if (!skillInput.trim()) return;
|
||||
const updated = [...profile.skills, skillInput.trim()];
|
||||
handleUpdateField('skills', updated);
|
||||
setSkillInput('');
|
||||
};
|
||||
|
||||
const handleRemoveSkill = (skill: string) => {
|
||||
const updated = profile.skills.filter(s => s !== skill);
|
||||
handleUpdateField('skills', updated);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSaveProfile(profile);
|
||||
setSavedSuccess(true);
|
||||
setTimeout(() => setSavedSuccess(false), 3000);
|
||||
} catch (err) {
|
||||
console.error('Error saving profile:', err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Top Banner */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-indigo-400" /> Master Resume & Profile Configuration
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
This master profile serves as the foundational knowledge base that Gemini AI uses to tailor your resumes and cover letters for every application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs shadow-lg shadow-indigo-600/30 disabled:opacity-50 flex items-center gap-2 transition-all shrink-0"
|
||||
>
|
||||
{savedSuccess ? <CheckCircle2 className="w-4 h-4 text-emerald-300" /> : <Save className="w-4 h-4" />}
|
||||
<span>{isSaving ? 'Saving...' : savedSuccess ? 'Saved to Firestore!' : 'Save Master Profile'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Profile Forms */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Personal Details */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl space-y-4">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2 pb-3 border-b border-slate-800">
|
||||
<User className="w-4 h-4 text-indigo-400" /> Personal Details
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profile.fullName}
|
||||
onChange={(e) => handleUpdateField('fullName', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
value={profile.email}
|
||||
onChange={(e) => handleUpdateField('email', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Phone Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profile.phone}
|
||||
onChange={(e) => handleUpdateField('phone', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Location / Relocation Preferences</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profile.location}
|
||||
onChange={(e) => handleUpdateField('location', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">LinkedIn Profile URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profile.linkedinUrl}
|
||||
onChange={(e) => handleUpdateField('linkedinUrl', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Master Professional Summary & Technical Skills */}
|
||||
<div className="lg:col-span-2 p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl space-y-5">
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2 pb-3 border-b border-slate-800 mb-3">
|
||||
<Sparkles className="w-4 h-4 text-amber-400" /> Master Executive Summary
|
||||
</h3>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={profile.summary}
|
||||
onChange={(e) => handleUpdateField('summary', e.target.value)}
|
||||
className="w-full p-3.5 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs leading-relaxed focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2 pb-3 border-b border-slate-800 mb-3">
|
||||
<Tag className="w-4 h-4 text-indigo-400" /> Technical & Professional Skills ({profile.skills.length})
|
||||
</h3>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Gemini API, Docker, System Design..."
|
||||
value={skillInput}
|
||||
onChange={(e) => setSkillInput(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddSkill}
|
||||
className="px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs"
|
||||
>
|
||||
Add Skill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{profile.skills.map((skill, idx) => (
|
||||
<span key={idx} className="px-3 py-1 rounded-xl bg-slate-950 text-slate-200 border border-slate-800 text-xs font-medium flex items-center gap-2">
|
||||
{skill}
|
||||
<button onClick={() => handleRemoveSkill(skill)} className="text-slate-500 hover:text-rose-400">✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* AI Engine & Local LLM Fallback Settings Card */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-slate-800">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4 text-purple-400" /> AI LLM Provider & Local Fallback Engine
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Configure Gemini API or automatically fail over to local LLMs (LM Studio, Ollama, vLLM, LocalAI) if Gemini quota is exhausted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center space-x-2 bg-purple-500/10 border border-purple-500/20 text-purple-300 text-xs px-3 py-1.5 rounded-xl font-medium shrink-0">
|
||||
<Server className="w-3.5 h-3.5" />
|
||||
<span>OpenAI-Compatible Local Endpoint</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-xs">
|
||||
{/* Left Column: Provider & Fallback Toggles */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1.5">Primary AI Completion Provider</label>
|
||||
<select
|
||||
value={llmConfig.primaryProvider}
|
||||
onChange={(e) => handleUpdateLLMConfig('primaryProvider', e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 font-medium focus:outline-none focus:border-purple-500"
|
||||
>
|
||||
<option value="gemini">Gemini 3.6 Flash (Cloud API)</option>
|
||||
<option value="lmstudio">Local LM Studio / Ollama (Local LLM)</option>
|
||||
<option value="openai_compatible">Custom OpenAI-Compatible API Endpoint</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-semibold text-slate-200">Automatic Fallback to Local LLM</span>
|
||||
<p className="text-[11px] text-slate-400">If Gemini hits rate limit (429) or token quota runs out, auto-route to LM Studio.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdateLLMConfig('fallbackToLocal', !llmConfig.fallbackToLocal)}
|
||||
className={`w-12 h-6 rounded-full transition-colors relative p-1 shrink-0 ${
|
||||
llmConfig.fallbackToLocal ? 'bg-purple-600' : 'bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
llmConfig.fallbackToLocal ? 'translate-x-6' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: LM Studio Endpoint Configuration */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Local LM Studio / OpenAI Endpoint Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="http://localhost:1234/v1"
|
||||
value={llmConfig.lmStudioBaseUrl}
|
||||
onChange={(e) => handleUpdateLLMConfig('lmStudioBaseUrl', e.target.value)}
|
||||
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 font-mono text-xs focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
<span className="text-[10px] text-slate-500 mt-1 block">Default LM Studio port is 1234. Ollama default port is 11434.</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">Local Model ID / Alias</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="local-model"
|
||||
value={llmConfig.lmStudioModel}
|
||||
onChange={(e) => handleUpdateLLMConfig('lmStudioModel', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1">API Key (Optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="lm-studio / bearer token"
|
||||
value={llmConfig.customApiKey || ''}
|
||||
onChange={(e) => handleUpdateLLMConfig('customApiKey', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-1 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestLocalLLM}
|
||||
disabled={isTestingLLM}
|
||||
className="px-4 py-2 rounded-xl bg-purple-600/20 hover:bg-purple-600/30 text-purple-300 border border-purple-500/30 font-semibold text-xs flex items-center gap-2 transition-all cursor-pointer"
|
||||
>
|
||||
{isTestingLLM ? (
|
||||
<>
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
<span>Pinging Local Server...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Test Local LM Studio Connection</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{llmTestResult && (
|
||||
<div className={`p-3 rounded-xl border text-xs flex items-center gap-2 ${
|
||||
llmTestResult.success
|
||||
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300'
|
||||
: 'bg-rose-500/10 border-rose-500/30 text-rose-300'
|
||||
}`}>
|
||||
{llmTestResult.success ? <CheckCircle2 className="w-4 h-4 shrink-0" /> : <AlertCircle className="w-4 h-4 shrink-0" />}
|
||||
<span>{llmTestResult.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Work Experience Section */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl space-y-4">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2 pb-3 border-b border-slate-800">
|
||||
<Briefcase className="w-4 h-4 text-indigo-400" /> Master Work Experience History
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{profile.experience.map((exp, expIdx) => (
|
||||
<div key={exp.id} className="p-4 rounded-xl bg-slate-950 border border-slate-800 text-xs space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 flex-1">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-400 mb-1">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={exp.title}
|
||||
onChange={(e) => {
|
||||
const copy = [...profile.experience];
|
||||
copy[expIdx].title = e.target.value;
|
||||
handleUpdateField('experience', copy);
|
||||
}}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-400 mb-1">Company</label>
|
||||
<input
|
||||
type="text"
|
||||
value={exp.company}
|
||||
onChange={(e) => {
|
||||
const copy = [...profile.experience];
|
||||
copy[expIdx].company = e.target.value;
|
||||
handleUpdateField('experience', copy);
|
||||
}}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-400 mb-1">Period</label>
|
||||
<input
|
||||
type="text"
|
||||
value={exp.period}
|
||||
onChange={(e) => {
|
||||
const copy = [...profile.experience];
|
||||
copy[expIdx].period = e.target.value;
|
||||
handleUpdateField('experience', copy);
|
||||
}}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-400 mb-1">Key Accomplishment Bullets</label>
|
||||
<div className="space-y-2">
|
||||
{exp.bullets.map((b, bulletIdx) => (
|
||||
<input
|
||||
key={bulletIdx}
|
||||
type="text"
|
||||
value={b}
|
||||
onChange={(e) => {
|
||||
const copy = [...profile.experience];
|
||||
copy[expIdx].bullets[bulletIdx] = e.target.value;
|
||||
handleUpdateField('experience', copy);
|
||||
}}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-slate-200 text-xs"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Briefcase,
|
||||
Sparkles,
|
||||
Bot,
|
||||
Rss,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Mail,
|
||||
FileText,
|
||||
Globe,
|
||||
Zap
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
pendingCount: number;
|
||||
autoAppliedCount: number;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
pendingCount,
|
||||
autoAppliedCount
|
||||
}) => {
|
||||
const navItems = [
|
||||
{
|
||||
id: 'applications',
|
||||
label: 'Applications Tracker',
|
||||
icon: Briefcase,
|
||||
badge: pendingCount > 0 ? `${pendingCount}` : undefined,
|
||||
badgeColor: 'bg-indigo-500/20 text-indigo-300 border-indigo-500/30'
|
||||
},
|
||||
{
|
||||
id: 'walkthrough',
|
||||
label: 'Job Walkthrough & Tailor',
|
||||
icon: Sparkles,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
id: 'auto-apply',
|
||||
label: 'Autonomous Auto-Apply',
|
||||
icon: Bot,
|
||||
badge: autoAppliedCount > 0 ? `${autoAppliedCount} Auto` : undefined,
|
||||
badgeColor: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30'
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: 'Google Workspace Hub',
|
||||
icon: Globe,
|
||||
badge: 'Sheets • Docs • Drive • Keep',
|
||||
badgeColor: 'bg-blue-500/20 text-blue-300 border-blue-500/30'
|
||||
},
|
||||
{
|
||||
id: 'feeds',
|
||||
label: 'Job Feeds & Openings',
|
||||
icon: Rss
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Analytics & Success Rates',
|
||||
icon: BarChart3
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
label: 'Google Calendar Schedule',
|
||||
icon: Calendar
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
label: 'Email Alert Logs',
|
||||
icon: Mail
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Master Resume & Criteria',
|
||||
icon: FileText
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="w-full md:w-64 bg-slate-900 border-r border-slate-800 flex flex-col p-3 gap-1 shrink-0">
|
||||
|
||||
<div className="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Portal Navigation
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={`flex items-center justify-between px-3.5 py-2.5 rounded-xl font-medium text-xs transition-all ${
|
||||
isActive
|
||||
? 'bg-indigo-600 text-white shadow-md shadow-indigo-600/30 font-semibold'
|
||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/80'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Icon className={`w-4 h-4 ${isActive ? 'text-white' : item.highlight ? 'text-amber-400' : 'text-slate-400'}`} />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
|
||||
{item.badge && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold border ${item.badgeColor}`}>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Quick AI Assist Card in Sidebar */}
|
||||
<div className="mt-auto pt-4 border-t border-slate-800/80 px-2">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-indigo-950/60 to-slate-900 border border-indigo-500/20 text-slate-300">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-indigo-300 mb-1">
|
||||
<Zap className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>AI Tailor Status</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 leading-snug">
|
||||
Gemini 3.6 Flash active for real-time resume tailoring & automated matching.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useState } from 'react';
|
||||
import { JobApplication } from '../types';
|
||||
import { FileText, Sparkles, Copy, Check, Download, X, ExternalLink, CloudUpload } from 'lucide-react';
|
||||
import { createTailoredGoogleDoc, uploadFileToGoogleDrive } from '../lib/googleAuth';
|
||||
|
||||
interface TailoredResumeModalProps {
|
||||
application: JobApplication;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const TailoredResumeModal: React.FC<TailoredResumeModalProps> = ({ application, onClose }) => {
|
||||
const [activeTab, setActiveTab] = useState<'resume' | 'cover'>('resume');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [workspaceStatus, setWorkspaceStatus] = useState<string | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const textToCopy = activeTab === 'resume' ? (application.tailoredResume || '') : (application.coverLetter || '');
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(textToCopy);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleExportToGoogleDocs = async () => {
|
||||
if (!textToCopy) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const title = `${activeTab === 'resume' ? 'Tailored Resume' : 'Cover Letter'} - ${application.company} (${application.role})`;
|
||||
const res = await createTailoredGoogleDoc(title, textToCopy, activeTab === 'resume' ? 'resume' : 'coverLetter');
|
||||
setWorkspaceStatus(`Exported to Google Docs!`);
|
||||
window.open(res.documentUrl, '_blank');
|
||||
} catch (err: any) {
|
||||
setWorkspaceStatus(`Export Error: ${err.message}`);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
setTimeout(() => setWorkspaceStatus(null), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveToDrive = async () => {
|
||||
if (!textToCopy) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const fileName = `${application.company}_${application.role}_${activeTab}.txt`;
|
||||
const res = await uploadFileToGoogleDrive(fileName, textToCopy, 'text/plain');
|
||||
setWorkspaceStatus(`Saved to Google Drive!`);
|
||||
window.open(res.driveUrl, '_blank');
|
||||
} catch (err: any) {
|
||||
setWorkspaceStatus(`Drive Error: ${err.message}`);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
setTimeout(() => setWorkspaceStatus(null), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-950/80 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-3xl bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl flex flex-col max-h-[85vh]">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-slate-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-600/20 text-indigo-400 border border-indigo-500/30 flex items-center justify-center font-bold">
|
||||
<FileText className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">Tailored Artifacts — {application.company}</h3>
|
||||
<p className="text-xs text-slate-400">{application.role} • Match Score: {application.matchScore}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={onClose} className="p-2 rounded-lg bg-slate-800 text-slate-400 hover:text-white">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Toggle & Status */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 my-4 border-b border-slate-800 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('resume')}
|
||||
className={`px-4 py-2 rounded-xl font-bold text-xs flex items-center gap-1.5 transition-all ${
|
||||
activeTab === 'resume'
|
||||
? 'bg-indigo-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Tailored Resume</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('cover')}
|
||||
className={`px-4 py-2 rounded-xl font-bold text-xs flex items-center gap-1.5 transition-all ${
|
||||
activeTab === 'cover'
|
||||
? 'bg-indigo-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-amber-400" />
|
||||
<span>Tailored Cover Letter</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{workspaceStatus && (
|
||||
<span className="text-xs font-semibold text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-3 py-1 rounded-lg">
|
||||
{workspaceStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text Area */}
|
||||
<div className="flex-1 overflow-hidden my-2">
|
||||
<textarea
|
||||
readOnly
|
||||
value={textToCopy || 'No tailored artifact generated yet for this record.'}
|
||||
className="w-full h-full min-h-[300px] p-4 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs leading-relaxed resize-none font-mono focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="pt-4 border-t border-slate-800 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleExportToGoogleDocs}
|
||||
disabled={isExporting || !textToCopy}
|
||||
className="px-3.5 py-1.5 rounded-xl bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 border border-blue-500/30 font-semibold text-xs flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Export to Google Docs</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSaveToDrive}
|
||||
disabled={isExporting || !textToCopy}
|
||||
className="px-3.5 py-1.5 rounded-xl bg-amber-600/20 hover:bg-amber-600/30 text-amber-300 border border-amber-500/30 font-semibold text-xs flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<CloudUpload className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Save to Google Drive</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="px-5 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs shadow-lg shadow-indigo-600/20 flex items-center gap-1.5"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-emerald-300" /> : <Copy className="w-4 h-4" />}
|
||||
<span>{copied ? 'Copied to Clipboard!' : 'Copy Artifact'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,395 @@
|
||||
import React, { useState } from 'react';
|
||||
import { UserProfile, JobApplication } from '../types';
|
||||
import {
|
||||
Sparkles,
|
||||
Link2,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Send,
|
||||
Copy,
|
||||
Check,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
Wand2,
|
||||
Bot,
|
||||
BrainCircuit,
|
||||
ListCheck
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WalkthroughTailorProps {
|
||||
userProfile: UserProfile;
|
||||
initialJobUrl?: string;
|
||||
onSaveTailoredApplication: (appData: Omit<JobApplication, 'id'>) => Promise<void>;
|
||||
}
|
||||
|
||||
export const WalkthroughTailor: React.FC<WalkthroughTailorProps> = ({
|
||||
userProfile,
|
||||
initialJobUrl = '',
|
||||
onSaveTailoredApplication
|
||||
}) => {
|
||||
const [jobUrl, setJobUrl] = useState(initialJobUrl);
|
||||
const [jobDescriptionText, setJobDescriptionText] = useState('');
|
||||
const [jobTitle, setJobTitle] = useState('');
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [platform, setPlatform] = useState<JobApplication['platform']>('LinkedIn');
|
||||
const [location, setLocation] = useState('Remote / SF');
|
||||
|
||||
const [isParsing, setIsParsing] = useState(false);
|
||||
const [isTailoring, setIsTailoring] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Analysis result
|
||||
const [tailorResult, setTailorResult] = useState<{
|
||||
matchScore: number;
|
||||
matchedSkills: string[];
|
||||
missingSkills: string[];
|
||||
tailoredBullets: string[];
|
||||
tailoredResumeText: string;
|
||||
coverLetter: string;
|
||||
insights: string;
|
||||
} | null>(null);
|
||||
|
||||
const [copiedType, setCopiedType] = useState<'resume' | 'cover' | null>(null);
|
||||
|
||||
// Step 1: Parse Job posting details using Gemini
|
||||
const handleParsePosting = async () => {
|
||||
if (!jobUrl && !jobDescriptionText) return;
|
||||
setIsParsing(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/gemini/parse-job-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jobUrl, rawText: jobDescriptionText }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
if (data.data.company) setCompanyName(data.data.company);
|
||||
if (data.data.role) setJobTitle(data.data.role);
|
||||
if (data.data.location) setLocation(data.data.location);
|
||||
if (data.data.platform) setPlatform(data.data.platform as any);
|
||||
if (data.data.jobDescriptionSummary && !jobDescriptionText) {
|
||||
setJobDescriptionText(data.data.jobDescriptionSummary);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing posting:', err);
|
||||
} finally {
|
||||
setIsParsing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Tailor Resume & Calculate Match via Gemini
|
||||
const handleGenerateTailored = async () => {
|
||||
if (!jobTitle || !companyName || !jobDescriptionText) return;
|
||||
setIsTailoring(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/gemini/tailor-resume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jobTitle,
|
||||
company: companyName,
|
||||
jobDescription: jobDescriptionText,
|
||||
userProfile
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
setTailorResult(data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error tailoring resume:', err);
|
||||
} finally {
|
||||
setIsTailoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 3: 1-Click Save or Auto-Submit Application
|
||||
const handleSubmitApplication = async (status: JobApplication['status']) => {
|
||||
if (!companyName || !jobTitle || !tailorResult) return;
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await onSaveTailoredApplication({
|
||||
company: companyName,
|
||||
role: jobTitle,
|
||||
platform: platform,
|
||||
location: location,
|
||||
status: status,
|
||||
matchScore: tailorResult.matchScore,
|
||||
appliedAt: new Date().toISOString(),
|
||||
jobUrl: jobUrl || undefined,
|
||||
jobDescription: jobDescriptionText,
|
||||
tailoredResume: tailorResult.tailoredResumeText,
|
||||
coverLetter: tailorResult.coverLetter,
|
||||
notes: tailorResult.insights,
|
||||
autoApplied: false,
|
||||
timeline: [
|
||||
{ date: new Date().toLocaleDateString(), action: `Submitted application tailored by AI` }
|
||||
]
|
||||
});
|
||||
alert(`Application for ${jobTitle} at ${companyName} saved successfully!`);
|
||||
} catch (err) {
|
||||
console.error('Error submitting application:', err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyText = (text: string, type: 'resume' | 'cover') => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedType(type);
|
||||
setTimeout(() => setCopiedType(null), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Step 1: Input Job Info */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-600/20 text-indigo-400 border border-indigo-500/30 flex items-center justify-center font-bold">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-white">Provide Job URL or Description</h2>
|
||||
<p className="text-xs text-slate-400">Paste the job posting link or full text to extract role criteria using Gemini AI.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Job URL (LinkedIn, Greenhouse, Lever, Workday...)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://boards.greenhouse.io/company/jobs/1234"
|
||||
value={jobUrl}
|
||||
onChange={(e) => setJobUrl(e.target.value)}
|
||||
className="flex-1 px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleParsePosting}
|
||||
disabled={isParsing || (!jobUrl && !jobDescriptionText)}
|
||||
className="px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-xs font-semibold flex items-center gap-1.5 transition-all"
|
||||
>
|
||||
<Wand2 className={`w-3.5 h-3.5 ${isParsing ? 'animate-spin' : ''}`} />
|
||||
<span>{isParsing ? 'Extracting...' : 'Extract Info'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Company Name</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. OpenAI"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Job Title</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Senior Software Engineer"
|
||||
value={jobTitle}
|
||||
onChange={(e) => setJobTitle(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Job Description / Requirements Text
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="Paste raw job posting responsibilities, skills, and qualifications here..."
|
||||
value={jobDescriptionText}
|
||||
onChange={(e) => setJobDescriptionText(e.target.value)}
|
||||
className="w-full p-3.5 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs focus:outline-none focus:border-indigo-500 leading-relaxed placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Generate Tailored Resume Button */}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={handleGenerateTailored}
|
||||
disabled={isTailoring || !jobTitle || !companyName || !jobDescriptionText}
|
||||
className="px-6 py-3 rounded-xl bg-gradient-to-r from-indigo-600 via-blue-600 to-cyan-500 hover:from-indigo-500 hover:to-cyan-400 text-white font-bold text-xs shadow-lg shadow-indigo-600/30 disabled:opacity-50 flex items-center gap-2 transition-all"
|
||||
>
|
||||
<Sparkles className={`w-4 h-4 ${isTailoring ? 'animate-spin' : ''}`} />
|
||||
<span>{isTailoring ? 'Generating AI Tailored Resume...' : 'Analyze & Tailor Resume Now'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Step 2: AI Tailored Output & Skill Gap Analysis */}
|
||||
{tailorResult && (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Match Score & Skill Breakdown Card */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-4 border-b border-slate-800">
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex items-center justify-center w-14 h-14 rounded-2xl bg-indigo-950 border border-indigo-500/40 text-indigo-300 font-extrabold text-xl shadow-inner">
|
||||
{tailorResult.matchScore}%
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">Match Analysis for {jobTitle}</h3>
|
||||
<p className="text-xs text-slate-400">{companyName} • {location}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleSubmitApplication('Applied')}
|
||||
disabled={isSubmitting}
|
||||
className="px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs shadow-lg shadow-emerald-600/20 flex items-center gap-1.5 transition-all"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>Mark as Applied & Save</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmitApplication('Saved')}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 font-medium text-xs transition-all"
|
||||
>
|
||||
Save as Draft
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Skill Alignment */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4 text-xs">
|
||||
|
||||
<div className="p-4 rounded-xl bg-slate-950 border border-slate-800">
|
||||
<div className="font-bold text-emerald-400 mb-2 flex items-center gap-1.5">
|
||||
<CheckCircle2 className="w-4 h-4" /> Matched Core Skills ({tailorResult.matchedSkills?.length || 0})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(tailorResult.matchedSkills || []).map((sk, idx) => (
|
||||
<span key={idx} className="px-2.5 py-1 rounded-lg bg-emerald-950/60 text-emerald-300 border border-emerald-500/30 text-[11px] font-medium">
|
||||
{sk}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-slate-950 border border-slate-800">
|
||||
<div className="font-bold text-amber-400 mb-2 flex items-center gap-1.5">
|
||||
<BrainCircuit className="w-4 h-4" /> Recommended Keywords to Highlight
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(tailorResult.missingSkills || []).map((sk, idx) => (
|
||||
<span key={idx} className="px-2.5 py-1 rounded-lg bg-amber-950/60 text-amber-300 border border-amber-500/30 text-[11px] font-medium">
|
||||
{sk}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Strategic Insights */}
|
||||
{tailorResult.insights && (
|
||||
<div className="mt-4 p-4 rounded-xl bg-indigo-950/40 border border-indigo-500/30 text-xs text-indigo-200">
|
||||
<div className="font-bold mb-1 flex items-center gap-1.5 text-indigo-300">
|
||||
<Sparkles className="w-3.5 h-3.5 text-amber-400" /> Strategic Application Insights
|
||||
</div>
|
||||
<p className="leading-relaxed text-slate-300">{tailorResult.insights}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Tailored Experience Bullet Points */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl">
|
||||
<h3 className="text-sm font-bold text-white mb-3 flex items-center gap-2">
|
||||
<ListCheck className="w-4 h-4 text-indigo-400" />
|
||||
Tailored Resume Experience Accomplishments
|
||||
</h3>
|
||||
<ul className="space-y-2 text-xs text-slate-300">
|
||||
{(tailorResult.tailoredBullets || []).map((bullet, idx) => (
|
||||
<li key={idx} className="p-3 rounded-xl bg-slate-950 border border-slate-800/80 leading-relaxed flex items-start gap-2.5">
|
||||
<span className="w-5 h-5 rounded-md bg-indigo-600/20 text-indigo-400 flex items-center justify-center font-bold shrink-0 text-[11px]">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span>{bullet}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Side by side: Tailored Resume vs Cover Letter */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
{/* Tailored Resume Text */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-3 mb-3 border-b border-slate-800">
|
||||
<h3 className="text-xs font-bold text-slate-200 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<FileText className="w-4 h-4 text-indigo-400" /> Full Tailored Resume
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => handleCopyText(tailorResult.tailoredResumeText, 'resume')}
|
||||
className="p-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs flex items-center gap-1"
|
||||
>
|
||||
{copiedType === 'resume' ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copiedType === 'resume' ? 'Copied!' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
rows={14}
|
||||
value={tailorResult.tailoredResumeText}
|
||||
className="w-full p-3 rounded-xl bg-slate-950 border border-slate-800 text-slate-300 text-xs font-mono leading-relaxed resize-none focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Cover Letter */}
|
||||
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-3 mb-3 border-b border-slate-800">
|
||||
<h3 className="text-xs font-bold text-slate-200 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Sparkles className="w-4 h-4 text-amber-400" /> Tailored Cover Letter
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => handleCopyText(tailorResult.coverLetter, 'cover')}
|
||||
className="p-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs flex items-center gap-1"
|
||||
>
|
||||
{copiedType === 'cover' ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copiedType === 'cover' ? 'Copied!' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
rows={14}
|
||||
value={tailorResult.coverLetter}
|
||||
className="w-full p-3 rounded-xl bg-slate-950 border border-slate-800 text-slate-300 text-xs leading-relaxed resize-none focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,329 @@
|
||||
import { JobApplication, JobFeedItem, UserProfile, NotificationLog, CalendarInterviewEvent } from '../types';
|
||||
|
||||
export const INITIAL_USER_PROFILE: UserProfile = {
|
||||
fullName: "Alex Morgan",
|
||||
email: "alex.morgan.tech@gmail.com",
|
||||
phone: "+1 (555) 382-9102",
|
||||
location: "San Francisco, CA (Hybrid / Remote)",
|
||||
linkedinUrl: "https://linkedin.com/in/alexmorgan-dev",
|
||||
githubUrl: "https://github.com/alexmorgan-dev",
|
||||
portfolioUrl: "https://alexmorgan.dev",
|
||||
summary: "Senior Full-Stack Software Engineer with 6+ years of experience building high-performance web applications, distributed systems, and AI-integrated developer tools. Passionate about micro-frontends, reactive UI architectures, and generative AI agent workflows.",
|
||||
experience: [
|
||||
{
|
||||
id: "exp_1",
|
||||
title: "Senior Full-Stack Engineer",
|
||||
company: "Apex Tech Systems",
|
||||
period: "2023 - Present",
|
||||
bullets: [
|
||||
"Architected enterprise React 18 & Node.js microservices serving 1.2M monthly active users with 99.98% uptime.",
|
||||
"Integrated LLM inference endpoints & Gemini AI vector search, boosting search accuracy by 42%.",
|
||||
"Optimized client-side bundle sizes and Vite build pipelines, cutting initial page load times from 3.2s to 850ms."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "exp_2",
|
||||
title: "Software Engineer",
|
||||
company: "CloudScale Innovations",
|
||||
period: "2020 - 2023",
|
||||
bullets: [
|
||||
"Developed scalable GraphQL & REST APIs in Express and TypeScript, handling over 25,000 requests per minute.",
|
||||
"Implemented real-time WebSocket collaborative dashboards and automated CI/CD deployment pipelines on GCP Cloud Run.",
|
||||
"Led a team of 4 engineers in migrating legacy monolithic monolith to containerized Docker services."
|
||||
]
|
||||
}
|
||||
],
|
||||
skills: [
|
||||
"TypeScript", "React", "Node.js", "Express", "Tailwind CSS",
|
||||
"Python", "Gemini API", "PostgreSQL", "Firebase", "GraphQL",
|
||||
"Docker", "Google Cloud Platform", "Jest", "Vite", "System Design"
|
||||
],
|
||||
education: [
|
||||
{
|
||||
degree: "B.S. in Computer Science",
|
||||
institution: "University of California, Berkeley",
|
||||
year: "2020"
|
||||
}
|
||||
],
|
||||
autoApplyCriteria: {
|
||||
enabled: true,
|
||||
targetRoles: ["Senior Software Engineer", "Full Stack Engineer", "Frontend Architect", "AI Integration Engineer"],
|
||||
minMatchScore: 82,
|
||||
maxDailyApplications: 10,
|
||||
preferredLocations: ["San Francisco, CA", "Remote US", "San Jose, CA"],
|
||||
minSalary: 155000,
|
||||
excludedCompanies: ["Legacy Systems Corp", "Monolith Inc"],
|
||||
preferredPlatforms: ["LinkedIn", "Greenhouse", "Lever", "Glassdoor", "Indeed", "Workday"]
|
||||
}
|
||||
};
|
||||
|
||||
export const INITIAL_APPLICATIONS: JobApplication[] = [
|
||||
{
|
||||
id: "app_101",
|
||||
company: "Stripe",
|
||||
role: "Senior Full Stack Engineer - Developer Experience",
|
||||
platform: "Greenhouse",
|
||||
location: "San Francisco, CA (Hybrid)",
|
||||
salaryRange: "$185,000 - $220,000",
|
||||
status: "Interviewing",
|
||||
matchScore: 94,
|
||||
appliedAt: "2026-07-18T14:30:00Z",
|
||||
jobUrl: "https://stripe.com/jobs/sr-fullstack-dev-exp",
|
||||
jobDescription: "Building next-generation developer tooling, SDK integrations, and interactive CLI dashboards in TypeScript and Node.js.",
|
||||
tailoredResume: "Tailored for Stripe: Emphasized developer experience tools, SDK design, performance optimizations, and reactive frontend architectures.",
|
||||
coverLetter: "Dear Stripe Team,\n\nI am thrilled to apply for the Senior Full Stack Engineer position on the Developer Experience team. With over 6 years of expertise building high-throughput TypeScript APIs and lightning-fast developer tools, I have consistently delivered platforms that make complex systems feel effortless...",
|
||||
interviewDate: new Date(Date.now() + 86400000 * 2).toISOString(), // 2 days from now
|
||||
calendarEventId: "evt_stripe_tech_screen",
|
||||
notes: "Technical Screen scheduled with Engineering Manager Sarah Chen. Focus on API design and microservice boundaries.",
|
||||
autoApplied: false,
|
||||
contactEmail: "recruiting@stripe.com",
|
||||
timeline: [
|
||||
{ date: "Jul 21, 10:15 AM", action: "Scheduled Technical Screen for Jul 24" },
|
||||
{ date: "Jul 19, 02:00 PM", action: "Recruiter Phone Screen Completed" },
|
||||
{ date: "Jul 18, 02:30 PM", action: "Application Submitted via Greenhouse" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "app_102",
|
||||
company: "Anthropic",
|
||||
role: "AI Agent & Infrastructure Engineer",
|
||||
platform: "Lever",
|
||||
location: "San Francisco, CA",
|
||||
salaryRange: "$210,000 - $260,000",
|
||||
status: "Screening",
|
||||
matchScore: 89,
|
||||
appliedAt: "2026-07-19T09:15:00Z",
|
||||
jobUrl: "https://jobs.lever.co/anthropic/ai-agent-infra",
|
||||
jobDescription: "Scaling generative AI agent loops, autonomous browser handshakes, and low-latency inference pipelines.",
|
||||
tailoredResume: "Tailored for Anthropic: Highlighted Gemini AI SDK integrations, server-side function calling, vector databases, and fault-tolerant agent execution.",
|
||||
coverLetter: "Dear Hiring Manager,\n\nAs a full-stack engineer passionate about autonomous AI agent loops and resilient backend architectures, I am eager to contribute to Anthropic's agent infrastructure...",
|
||||
interviewDate: undefined,
|
||||
notes: "Recruiter screen scheduled for Friday morning.",
|
||||
autoApplied: true,
|
||||
contactEmail: "careers@anthropic.com",
|
||||
timeline: [
|
||||
{ date: "Jul 20, 04:45 PM", action: "Recruiter requested availability for screening" },
|
||||
{ date: "Jul 19, 09:15 AM", action: "Autonomous AI Auto-Applied (Match: 89%)" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "app_103",
|
||||
company: "Figma",
|
||||
role: "Staff Frontend Architect - Canvas Engine",
|
||||
platform: "LinkedIn",
|
||||
location: "San Francisco, CA / Remote",
|
||||
salaryRange: "$200,000 - $245,000",
|
||||
status: "Applied",
|
||||
matchScore: 86,
|
||||
appliedAt: "2026-07-20T11:00:00Z",
|
||||
jobUrl: "https://www.linkedin.com/jobs/view/figma-staff-frontend",
|
||||
jobDescription: "Optimizing high-performance web canvas rendering, WebAssembly modules, and real-time multiplayer state synchronization.",
|
||||
tailoredResume: "Tailored for Figma: Focused on WebGL/Canvas rendering, sub-second latency state management, micro-frontends, and Vite bundler tuning.",
|
||||
coverLetter: "Dear Figma Hiring Team,\n\nI have followed Figma's engineering blogs on multiplayer WebGL rendering with deep admiration. My background in building real-time reactive frontend tools makes me a natural fit...",
|
||||
notes: "Awaiting recruiter response. Reached out to UC Berkeley alumni at Figma.",
|
||||
autoApplied: false,
|
||||
contactEmail: "talent@figma.com",
|
||||
timeline: [
|
||||
{ date: "Jul 20, 11:00 AM", action: "Application Submitted via LinkedIn" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "app_104",
|
||||
company: "Vercel",
|
||||
role: "Senior Edge Infrastructure Engineer",
|
||||
platform: "Greenhouse",
|
||||
location: "Remote US",
|
||||
salaryRange: "$175,000 - $210,000",
|
||||
status: "Offer",
|
||||
matchScore: 92,
|
||||
appliedAt: "2026-07-10T16:20:00Z",
|
||||
jobUrl: "https://vercel.com/careers/sr-edge-eng",
|
||||
jobDescription: "Building serverless edge runtime primitives, global routing proxies, and developer preview pipelines.",
|
||||
tailoredResume: "Tailored for Vercel: Showcased SSR frameworks, Cloud Run deployment containers, edge routing, and bundle optimization.",
|
||||
interviewDate: undefined,
|
||||
notes: "Offer received! Base: $195k + Equity. Decision deadline: July 28.",
|
||||
autoApplied: true,
|
||||
contactEmail: "offers@vercel.com",
|
||||
timeline: [
|
||||
{ date: "Jul 21, 05:00 PM", action: "Official Written Offer Letter Delivered!" },
|
||||
{ date: "Jul 17, 02:00 PM", action: "Final Executive Round Completed" },
|
||||
{ date: "Jul 14, 11:00 AM", action: "System Design Panel Completed" },
|
||||
{ date: "Jul 10, 04:20 PM", action: "Autonomous AI Auto-Applied (Match: 92%)" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "app_105",
|
||||
company: "Datadog",
|
||||
role: "Senior Software Engineer - Telemetry",
|
||||
platform: "Glassdoor",
|
||||
location: "San Jose, CA",
|
||||
salaryRange: "$170,000 - $205,000",
|
||||
status: "Rejected",
|
||||
matchScore: 78,
|
||||
appliedAt: "2026-07-12T10:00:00Z",
|
||||
jobUrl: "https://www.glassdoor.com/job-listing/datadog-sr-eng",
|
||||
jobDescription: "High-throughput metric ingestion pipelines, distributed tracing, and real-time observability dashboards.",
|
||||
tailoredResume: "Tailored for Datadog: Highlighted metric aggregations, REST APIs, and telemetry pipelines.",
|
||||
notes: "Positional fit was slightly off due to heavy C++ requirement.",
|
||||
autoApplied: true,
|
||||
timeline: [
|
||||
{ date: "Jul 16, 09:00 AM", action: "Position filled / Rejection notice received" },
|
||||
{ date: "Jul 12, 10:00 AM", action: "Application Submitted via Glassdoor" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "app_106",
|
||||
company: "Airbnb",
|
||||
role: "Senior Staff Full Stack Architect",
|
||||
platform: "Workday",
|
||||
location: "San Francisco, CA / Hybrid",
|
||||
salaryRange: "$195,000 - $240,000",
|
||||
status: "Applied",
|
||||
matchScore: 88,
|
||||
appliedAt: "2026-07-21T08:30:00Z",
|
||||
jobUrl: "https://airbnb.workdayjobs.com/careers/job/sr-staff-fullstack",
|
||||
jobDescription: "Scaling design system components, server-driven UI, and seamless international search experiences.",
|
||||
tailoredResume: "Tailored for Airbnb: Emphasized server-driven UI design, modular React architecture, and global localization.",
|
||||
autoApplied: true,
|
||||
timeline: [
|
||||
{ date: "Jul 21, 08:30 AM", action: "Autonomous AI Auto-Applied (Match: 88%)" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const INITIAL_JOB_FEEDS: JobFeedItem[] = [
|
||||
{
|
||||
id: "feed_1",
|
||||
title: "Senior AI Integration Engineer",
|
||||
company: "OpenAI Platform Partner",
|
||||
location: "San Francisco, CA (Hybrid)",
|
||||
salary: "$190,000 - $230,000",
|
||||
platform: "Greenhouse",
|
||||
url: "https://boards.greenhouse.io/openaiplatform/jobs/4829101",
|
||||
postedDate: "2 hours ago",
|
||||
description: "Looking for a seasoned TypeScript/Node engineer to integrate Gemini and OpenAI APIs into customer agent workflows, build resilient streaming proxies, and maintain fast client UI interfaces.",
|
||||
requiredSkills: ["TypeScript", "Node.js", "Gemini API", "Express", "Streaming SSE", "Tailwind CSS"],
|
||||
estimatedMatch: 95,
|
||||
type: "Full-time"
|
||||
},
|
||||
{
|
||||
id: "feed_2",
|
||||
title: "Lead Full Stack Developer",
|
||||
company: "Linear Technologies",
|
||||
location: "Remote US / SF",
|
||||
salary: "$180,000 - $215,000",
|
||||
platform: "Lever",
|
||||
url: "https://jobs.lever.co/linear/lead-fullstack",
|
||||
postedDate: "5 hours ago",
|
||||
description: "Architecting buttery-smooth issue tracking workflows, high-frequency keyboard shortcuts, and optimistic client state synchronization in React & Node.",
|
||||
requiredSkills: ["React", "TypeScript", "Vite", "GraphQL", "Performance", "State Sync"],
|
||||
estimatedMatch: 91,
|
||||
type: "Remote"
|
||||
},
|
||||
{
|
||||
id: "feed_3",
|
||||
title: "Staff Frontend Architect",
|
||||
company: "Notion",
|
||||
location: "San Francisco, CA",
|
||||
salary: "$205,000 - $250,000",
|
||||
platform: "LinkedIn",
|
||||
url: "https://www.linkedin.com/jobs/view/notion-staff-frontend",
|
||||
postedDate: "1 day ago",
|
||||
description: "Drive core document editor performance, micro-frontend modules, rich text parsing, and web worker background processes.",
|
||||
requiredSkills: ["React", "TypeScript", "Micro-frontends", "Vite", "Canvas/DOM", "System Architecture"],
|
||||
estimatedMatch: 87,
|
||||
type: "Hybrid"
|
||||
},
|
||||
{
|
||||
id: "feed_4",
|
||||
title: "Senior Cloud Infrastructure Engineer",
|
||||
company: "Supabase",
|
||||
location: "Remote Worldwide",
|
||||
salary: "$165,000 - $200,000",
|
||||
platform: "Direct",
|
||||
url: "https://supabase.com/careers/cloud-infra-eng",
|
||||
postedDate: "1 day ago",
|
||||
description: "Build global database backup orchestration, serverless edge function execution layers, and automated cloud monitoring APIs.",
|
||||
requiredSkills: ["PostgreSQL", "Docker", "Go/Node.js", "Cloud Run/GCP", "REST APIs"],
|
||||
estimatedMatch: 83,
|
||||
type: "Remote"
|
||||
},
|
||||
{
|
||||
id: "feed_5",
|
||||
title: "Principal Web Engineer",
|
||||
company: "Scale AI",
|
||||
location: "San Francisco, CA",
|
||||
salary: "$210,000 - $270,000",
|
||||
platform: "Workday",
|
||||
url: "https://scaleai.workdayjobs.com/careers/principal-web",
|
||||
postedDate: "3 days ago",
|
||||
description: "Lead engineering on human-in-the-loop annotation interfaces, LLM evaluation analytics dashboards, and interactive synthetic data tools.",
|
||||
requiredSkills: ["TypeScript", "React", "Recharts/D3", "Node.js", "Python", "Data Viz"],
|
||||
estimatedMatch: 89,
|
||||
type: "Full-time"
|
||||
}
|
||||
];
|
||||
|
||||
export const INITIAL_CALENDAR_EVENTS: CalendarInterviewEvent[] = [
|
||||
{
|
||||
id: "evt_1",
|
||||
applicationId: "app_101",
|
||||
company: "Stripe",
|
||||
role: "Senior Full Stack Engineer",
|
||||
interviewType: "Technical Round",
|
||||
startTime: new Date(Date.now() + 86400000 * 2).toISOString().split('T')[0] + "T10:00:00-07:00",
|
||||
endTime: new Date(Date.now() + 86400000 * 2).toISOString().split('T')[0] + "T11:00:00-07:00",
|
||||
locationOrUrl: "https://meet.google.com/stripe-tech-screen",
|
||||
googleCalendarEventId: "gcal_evt_stripe_101",
|
||||
syncedToGoogle: true,
|
||||
interviewer: "Sarah Chen (Engineering Manager)",
|
||||
notes: "Review REST API design guidelines and reactive client state."
|
||||
},
|
||||
{
|
||||
id: "evt_2",
|
||||
applicationId: "app_102",
|
||||
company: "Anthropic",
|
||||
role: "AI Agent & Infrastructure Engineer",
|
||||
interviewType: "Phone Screen",
|
||||
startTime: new Date(Date.now() + 86400000 * 3).toISOString().split('T')[0] + "T14:30:00-07:00",
|
||||
endTime: new Date(Date.now() + 86400000 * 3).toISOString().split('T')[0] + "T15:00:00-07:00",
|
||||
locationOrUrl: "+1 (415) 890-2193",
|
||||
googleCalendarEventId: "gcal_evt_anthropic_102",
|
||||
syncedToGoogle: true,
|
||||
interviewer: "Marcus Vance (Talent Acquisition)",
|
||||
notes: "Discuss experience with Gemini AI SDK and agent fallback mechanisms."
|
||||
}
|
||||
];
|
||||
|
||||
export const INITIAL_NOTIFICATIONS: NotificationLog[] = [
|
||||
{
|
||||
id: "notif_1",
|
||||
timestamp: "2026-07-21T10:15:00Z",
|
||||
type: "INTERVIEW_SCHEDULED",
|
||||
recipient: "alex.morgan.tech@gmail.com",
|
||||
subject: "Interview Scheduled: Stripe - Senior Full Stack Engineer",
|
||||
body: "Your Technical Screen with Stripe has been confirmed for " + new Date(Date.now() + 86400000 * 2).toLocaleDateString() + " at 10:00 AM PDT. A Google Calendar invitation has been synced automatically.",
|
||||
status: "SENT",
|
||||
platform: "Google Calendar & Gmail"
|
||||
},
|
||||
{
|
||||
id: "notif_2",
|
||||
timestamp: "2026-07-21T08:30:00Z",
|
||||
type: "AUTO_APPLY",
|
||||
recipient: "alex.morgan.tech@gmail.com",
|
||||
subject: "Autonomous AI Submitted Application: Airbnb (Match: 88%)",
|
||||
body: "Our Autonomous AI Auto-Apply engine has successfully tailored your resume and submitted an application for Senior Staff Full Stack Architect at Airbnb via Workday.",
|
||||
status: "SENT",
|
||||
platform: "Gmail"
|
||||
},
|
||||
{
|
||||
id: "notif_3",
|
||||
timestamp: "2026-07-19T09:15:00Z",
|
||||
type: "AUTO_APPLY",
|
||||
recipient: "alex.morgan.tech@gmail.com",
|
||||
subject: "Autonomous AI Submitted Application: Anthropic (Match: 89%)",
|
||||
body: "Application submitted to Anthropic via Lever. Tailored experience section added for LLM infrastructure and Gemini integrations.",
|
||||
status: "SENT",
|
||||
platform: "Gmail"
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -0,0 +1,185 @@
|
||||
import { initializeApp, getApps, getApp } from 'firebase/app';
|
||||
import {
|
||||
getFirestore,
|
||||
collection,
|
||||
doc,
|
||||
getDocs,
|
||||
getDoc,
|
||||
setDoc,
|
||||
updateDoc,
|
||||
deleteDoc,
|
||||
addDoc,
|
||||
query,
|
||||
orderBy
|
||||
} from 'firebase/firestore';
|
||||
import firebaseConfig from '../../firebase-applet-config.json';
|
||||
import { JobApplication, UserProfile, NotificationLog, CalendarInterviewEvent } from '../types';
|
||||
|
||||
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApp();
|
||||
export const db = getFirestore(app);
|
||||
|
||||
const APPS_COLLECTION = 'applications';
|
||||
const PROFILE_DOC = 'user_profile';
|
||||
const NOTIFICATIONS_COLLECTION = 'notifications';
|
||||
const CALENDAR_COLLECTION = 'calendar_events';
|
||||
|
||||
export const isFirebaseConfigured = true;
|
||||
|
||||
// Firestore Helper Functions with LocalStorage Sync Fallback
|
||||
|
||||
export async function fetchApplications(): Promise<JobApplication[]> {
|
||||
try {
|
||||
const q = query(collection(db, APPS_COLLECTION));
|
||||
const snapshot = await getDocs(q);
|
||||
if (snapshot.empty) return [];
|
||||
return snapshot.docs.map(docSnap => ({ id: docSnap.id, ...docSnap.data() } as JobApplication));
|
||||
} catch (err) {
|
||||
console.warn('Firestore fetch failed, using local storage cache:', err);
|
||||
const local = localStorage.getItem('ai_job_portal_applications');
|
||||
return local ? JSON.parse(local) : [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveApplication(appData: Omit<JobApplication, 'id'> & { id?: string }): Promise<JobApplication> {
|
||||
const id = appData.id || `app_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
||||
const record: JobApplication = { ...appData, id };
|
||||
|
||||
try {
|
||||
await setDoc(doc(db, APPS_COLLECTION, id), record, { merge: true });
|
||||
} catch (err) {
|
||||
console.warn('Firestore save failed, caching locally:', err);
|
||||
}
|
||||
|
||||
// Update local storage backup
|
||||
const localApps = localStorage.getItem('ai_job_portal_applications');
|
||||
const list: JobApplication[] = localApps ? JSON.parse(localApps) : [];
|
||||
const idx = list.findIndex(a => a.id === id);
|
||||
if (idx >= 0) list[idx] = record;
|
||||
else list.unshift(record);
|
||||
localStorage.setItem('ai_job_portal_applications', JSON.stringify(list));
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function updateApplicationStatus(id: string, newStatus: JobApplication['status'], note?: string): Promise<void> {
|
||||
const timestamp = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
const timelineItem = { date: timestamp, action: `Status changed to ${newStatus}`, details: note };
|
||||
|
||||
try {
|
||||
const docRef = doc(db, APPS_COLLECTION, id);
|
||||
const snap = await getDoc(docRef);
|
||||
if (snap.exists()) {
|
||||
const existing = snap.data() as JobApplication;
|
||||
const timeline = existing.timeline || [];
|
||||
timeline.unshift(timelineItem);
|
||||
await updateDoc(docRef, { status: newStatus, timeline });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Firestore update failed:', err);
|
||||
}
|
||||
|
||||
// Local storage update
|
||||
const localApps = localStorage.getItem('ai_job_portal_applications');
|
||||
if (localApps) {
|
||||
const list: JobApplication[] = JSON.parse(localApps);
|
||||
const idx = list.findIndex(a => a.id === id);
|
||||
if (idx >= 0) {
|
||||
list[idx].status = newStatus;
|
||||
if (!list[idx].timeline) list[idx].timeline = [];
|
||||
list[idx].timeline!.unshift(timelineItem);
|
||||
localStorage.setItem('ai_job_portal_applications', JSON.stringify(list));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteApplicationRecord(id: string): Promise<void> {
|
||||
try {
|
||||
await deleteDoc(doc(db, APPS_COLLECTION, id));
|
||||
} catch (err) {
|
||||
console.warn('Firestore delete failed:', err);
|
||||
}
|
||||
|
||||
const localApps = localStorage.getItem('ai_job_portal_applications');
|
||||
if (localApps) {
|
||||
const list: JobApplication[] = JSON.parse(localApps);
|
||||
const filtered = list.filter(a => a.id !== id);
|
||||
localStorage.setItem('ai_job_portal_applications', JSON.stringify(filtered));
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchUserProfile(): Promise<UserProfile | null> {
|
||||
try {
|
||||
const snap = await getDoc(doc(db, 'profiles', PROFILE_DOC));
|
||||
if (snap.exists()) return snap.data() as UserProfile;
|
||||
} catch (err) {
|
||||
console.warn('Firestore fetch profile failed:', err);
|
||||
}
|
||||
const local = localStorage.getItem('ai_job_portal_user_profile');
|
||||
return local ? JSON.parse(local) : null;
|
||||
}
|
||||
|
||||
export async function saveUserProfile(profile: UserProfile): Promise<void> {
|
||||
try {
|
||||
await setDoc(doc(db, 'profiles', PROFILE_DOC), profile, { merge: true });
|
||||
} catch (err) {
|
||||
console.warn('Firestore save profile failed:', err);
|
||||
}
|
||||
localStorage.setItem('ai_job_portal_user_profile', JSON.stringify(profile));
|
||||
}
|
||||
|
||||
export async function fetchNotifications(): Promise<NotificationLog[]> {
|
||||
try {
|
||||
const q = query(collection(db, NOTIFICATIONS_COLLECTION));
|
||||
const snap = await getDocs(q);
|
||||
return snap.docs.map(d => ({ id: d.id, ...d.data() } as NotificationLog));
|
||||
} catch (err) {
|
||||
const local = localStorage.getItem('ai_job_portal_notifications');
|
||||
return local ? JSON.parse(local) : [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveNotificationLog(notif: Omit<NotificationLog, 'id'>): Promise<NotificationLog> {
|
||||
const id = `notif_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`;
|
||||
const record: NotificationLog = { ...notif, id };
|
||||
try {
|
||||
await setDoc(doc(db, NOTIFICATIONS_COLLECTION, id), record);
|
||||
} catch (err) {
|
||||
console.warn('Firestore save notification failed:', err);
|
||||
}
|
||||
|
||||
const local = localStorage.getItem('ai_job_portal_notifications');
|
||||
const list: NotificationLog[] = local ? JSON.parse(local) : [];
|
||||
list.unshift(record);
|
||||
localStorage.setItem('ai_job_portal_notifications', JSON.stringify(list));
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function fetchCalendarEvents(): Promise<CalendarInterviewEvent[]> {
|
||||
try {
|
||||
const snap = await getDocs(collection(db, CALENDAR_COLLECTION));
|
||||
return snap.docs.map(d => ({ id: d.id, ...d.data() } as CalendarInterviewEvent));
|
||||
} catch (err) {
|
||||
const local = localStorage.getItem('ai_job_portal_calendar');
|
||||
return local ? JSON.parse(local) : [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCalendarEvent(event: Omit<CalendarInterviewEvent, 'id'> & { id?: string }): Promise<CalendarInterviewEvent> {
|
||||
const id = event.id || `evt_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`;
|
||||
const record: CalendarInterviewEvent = { ...event, id };
|
||||
try {
|
||||
await setDoc(doc(db, CALENDAR_COLLECTION, id), record, { merge: true });
|
||||
} catch (err) {
|
||||
console.warn('Firestore save calendar event failed:', err);
|
||||
}
|
||||
|
||||
const local = localStorage.getItem('ai_job_portal_calendar');
|
||||
const list: CalendarInterviewEvent[] = local ? JSON.parse(local) : [];
|
||||
const idx = list.findIndex(e => e.id === id);
|
||||
if (idx >= 0) list[idx] = record;
|
||||
else list.push(record);
|
||||
localStorage.setItem('ai_job_portal_calendar', JSON.stringify(list));
|
||||
|
||||
return record;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { initializeApp, getApps, getApp } from 'firebase/app';
|
||||
import {
|
||||
getAuth,
|
||||
signInWithPopup,
|
||||
GoogleAuthProvider,
|
||||
onAuthStateChanged,
|
||||
User,
|
||||
signOut
|
||||
} from 'firebase/auth';
|
||||
import firebaseConfig from '../../firebase-applet-config.json';
|
||||
import { JobApplication } from '../types';
|
||||
|
||||
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApp();
|
||||
export const auth = getAuth(app);
|
||||
|
||||
const provider = new GoogleAuthProvider();
|
||||
// Workspace Scopes
|
||||
provider.addScope('https://www.googleapis.com/auth/spreadsheets');
|
||||
provider.addScope('https://www.googleapis.com/auth/drive');
|
||||
provider.addScope('https://www.googleapis.com/auth/documents');
|
||||
|
||||
let isSigningIn = false;
|
||||
let cachedAccessToken: string | null = null;
|
||||
|
||||
export const initAuthListener = (
|
||||
onSuccess: (user: User, token: string) => void,
|
||||
onFailure: () => void
|
||||
) => {
|
||||
return onAuthStateChanged(auth, async (user) => {
|
||||
if (user) {
|
||||
if (cachedAccessToken) {
|
||||
onSuccess(user, cachedAccessToken);
|
||||
} else if (!isSigningIn) {
|
||||
// If logged in without token in memory (e.g., page refresh), trigger quick re-auth if needed
|
||||
onFailure();
|
||||
}
|
||||
} else {
|
||||
cachedAccessToken = null;
|
||||
onFailure();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const googleWorkspaceSignIn = async (): Promise<{ user: User; accessToken: string } | null> => {
|
||||
try {
|
||||
isSigningIn = true;
|
||||
const result = await signInWithPopup(auth, provider);
|
||||
const credential = GoogleAuthProvider.credentialFromResult(result);
|
||||
if (!credential?.accessToken) {
|
||||
throw new Error('Failed to obtain Google access token');
|
||||
}
|
||||
cachedAccessToken = credential.accessToken;
|
||||
return { user: result.user, accessToken: cachedAccessToken };
|
||||
} catch (error) {
|
||||
console.error('Google Workspace Sign-in Error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
isSigningIn = false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCachedAccessToken = (): string | null => {
|
||||
return cachedAccessToken;
|
||||
};
|
||||
|
||||
export const googleSignOut = async (): Promise<void> => {
|
||||
await signOut(auth);
|
||||
cachedAccessToken = null;
|
||||
};
|
||||
|
||||
// Workspace Action Handlers
|
||||
|
||||
export async function exportApplicationsToGoogleSheets(
|
||||
applications: JobApplication[],
|
||||
accessToken?: string
|
||||
): Promise<{ spreadsheetId: string; spreadsheetUrl: string }> {
|
||||
const token = accessToken || cachedAccessToken;
|
||||
const res = await fetch('/api/workspace/sheets/export', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ applications, accessToken: token })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to export applications to Google Sheets');
|
||||
}
|
||||
return { spreadsheetId: data.spreadsheetId, spreadsheetUrl: data.spreadsheetUrl };
|
||||
}
|
||||
|
||||
export async function createTailoredGoogleDoc(
|
||||
title: string,
|
||||
content: string,
|
||||
docType: 'resume' | 'coverLetter' | 'note',
|
||||
accessToken?: string
|
||||
): Promise<{ documentId: string; documentUrl: string }> {
|
||||
const token = accessToken || cachedAccessToken;
|
||||
const res = await fetch('/api/workspace/docs/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ title, content, docType, accessToken: token })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to create Google Doc');
|
||||
}
|
||||
return { documentId: data.documentId, documentUrl: data.documentUrl };
|
||||
}
|
||||
|
||||
export async function uploadFileToGoogleDrive(
|
||||
fileName: string,
|
||||
fileContent: string,
|
||||
mimeType: string = 'text/plain',
|
||||
accessToken?: string
|
||||
): Promise<{ fileId: string; driveUrl: string }> {
|
||||
const token = accessToken || cachedAccessToken;
|
||||
const res = await fetch('/api/workspace/drive/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ fileName, fileContent, mimeType, accessToken: token })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to upload file to Google Drive');
|
||||
}
|
||||
return { fileId: data.fileId, driveUrl: data.driveUrl };
|
||||
}
|
||||
|
||||
export async function listGoogleDriveWorkspaceFiles(accessToken?: string): Promise<any[]> {
|
||||
const token = accessToken || cachedAccessToken;
|
||||
const res = await fetch('/api/workspace/drive/files', {
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.files || [];
|
||||
}
|
||||
|
||||
export async function saveGoogleKeepNote(
|
||||
title: string,
|
||||
content: string,
|
||||
color: string = 'DEFAULT',
|
||||
accessToken?: string
|
||||
): Promise<{ noteId: string; title: string; content: string; color: string; timestamp: string }> {
|
||||
const token = accessToken || cachedAccessToken;
|
||||
const res = await fetch('/api/workspace/keep/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ title, content, color, accessToken: token })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to create Keep note');
|
||||
}
|
||||
return data.note;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
export type ApplicationStatus =
|
||||
| 'Saved'
|
||||
| 'Applying'
|
||||
| 'Applied'
|
||||
| 'Screening'
|
||||
| 'Interviewing'
|
||||
| 'Offer'
|
||||
| 'Rejected'
|
||||
| 'Withdrawn';
|
||||
|
||||
export type PlatformType =
|
||||
| 'LinkedIn'
|
||||
| 'Indeed'
|
||||
| 'Glassdoor'
|
||||
| 'Greenhouse'
|
||||
| 'Lever'
|
||||
| 'Workday'
|
||||
| 'Direct';
|
||||
|
||||
export interface JobApplication {
|
||||
id: string;
|
||||
company: string;
|
||||
role: string;
|
||||
platform: PlatformType;
|
||||
location: string;
|
||||
salaryRange?: string;
|
||||
status: ApplicationStatus;
|
||||
matchScore: number;
|
||||
appliedAt: string;
|
||||
jobUrl?: string;
|
||||
jobDescription?: string;
|
||||
tailoredResume?: string;
|
||||
coverLetter?: string;
|
||||
interviewDate?: string; // ISO string
|
||||
calendarEventId?: string;
|
||||
notes?: string;
|
||||
autoApplied?: boolean;
|
||||
contactEmail?: string;
|
||||
timeline?: {
|
||||
date: string;
|
||||
action: string;
|
||||
details?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface JobFeedItem {
|
||||
id: string;
|
||||
title: string;
|
||||
company: string;
|
||||
location: string;
|
||||
salary: string;
|
||||
platform: PlatformType;
|
||||
url: string;
|
||||
postedDate: string;
|
||||
description: string;
|
||||
requiredSkills: string[];
|
||||
estimatedMatch: number;
|
||||
type: 'Full-time' | 'Contract' | 'Remote' | 'Hybrid';
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
location: string;
|
||||
linkedinUrl: string;
|
||||
githubUrl: string;
|
||||
portfolioUrl: string;
|
||||
summary: string;
|
||||
experience: {
|
||||
id: string;
|
||||
title: string;
|
||||
company: string;
|
||||
period: string;
|
||||
bullets: string[];
|
||||
}[];
|
||||
skills: string[];
|
||||
education: {
|
||||
degree: string;
|
||||
institution: string;
|
||||
year: string;
|
||||
}[];
|
||||
|
||||
// Autonomous settings
|
||||
autoApplyCriteria: {
|
||||
enabled: boolean;
|
||||
targetRoles: string[];
|
||||
minMatchScore: number;
|
||||
maxDailyApplications: number;
|
||||
preferredLocations: string[];
|
||||
minSalary: number;
|
||||
excludedCompanies: string[];
|
||||
preferredPlatforms: PlatformType[];
|
||||
};
|
||||
|
||||
// LLM Provider & Local LM Studio Fallback Settings
|
||||
llmConfig?: {
|
||||
primaryProvider: 'gemini' | 'lmstudio' | 'openai_compatible';
|
||||
fallbackToLocal: boolean;
|
||||
lmStudioBaseUrl: string; // e.g., http://localhost:1234/v1 or http://127.0.0.1:1234/v1
|
||||
lmStudioModel: string; // e.g., local-model or qwen2.5-coder-7b-instruct
|
||||
customApiKey?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationLog {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: 'AUTO_APPLY' | 'INTERVIEW_SCHEDULED' | 'STATUS_CHANGE' | 'WEEKLY_DIGEST' | 'SYSTEM';
|
||||
recipient: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
status: 'SENT' | 'FAILED' | 'QUEUED';
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
export interface CalendarInterviewEvent {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
company: string;
|
||||
role: string;
|
||||
interviewType: 'Phone Screen' | 'Technical Round' | 'Behavioral' | 'System Design' | 'Final Round';
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
locationOrUrl?: string;
|
||||
googleCalendarEventId?: string;
|
||||
syncedToGoogle: boolean;
|
||||
interviewer?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface AutoApplyLog {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
company: string;
|
||||
role: string;
|
||||
matchScore: number;
|
||||
status: 'SUCCESS' | 'SKIPPED' | 'FAILED';
|
||||
reason?: string;
|
||||
resumeTailored: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user