169 lines
7.7 KiB
TypeScript
169 lines
7.7 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||
import { ChevronRight, Clock, Mic, Gauge, ArrowLeft } from 'lucide-react';
|
||
import { useJobPostings, useCreateApplication, useCourses } from '@/lib/krowHooks';
|
||
import { YourSkillFit } from '@/components/krow/MatchedCandidates';
|
||
import { verifiedLevels } from '@/lib/skillGraph';
|
||
import ApplyIdentityCard from '@/components/krow/ApplyIdentityCard';
|
||
import AIInterviewModal from '@/components/krow/AIInterviewModal';
|
||
import { useWorkerProfile } from '@/lib/krowHooks';
|
||
import { toast } from 'react-hot-toast';
|
||
import { useTranslation } from 'react-i18next';
|
||
|
||
export default function Apply() {
|
||
const { t } = useTranslation();
|
||
const navigate = useNavigate();
|
||
const [searchParams] = useSearchParams();
|
||
const jobId = searchParams.get('job');
|
||
const { data: postings = [], isLoading } = useJobPostings();
|
||
const createApplication = useCreateApplication();
|
||
const { data: profile } = useWorkerProfile();
|
||
const { data: courses = [] } = useCourses();
|
||
const [selectedJob, setSelectedJob] = useState(null);
|
||
const [showForm, setShowForm] = useState(false);
|
||
const [interviewApp, setInterviewApp] = useState(null);
|
||
const [starting, setStarting] = useState(false);
|
||
|
||
const openPositions = postings.filter(p => p.status === 'active');
|
||
|
||
// Deep-link from invitation: auto-select job if ?job=<id> is in URL
|
||
useEffect(() => {
|
||
if (jobId && !isLoading) {
|
||
const job = openPositions.find(p => p.id === jobId);
|
||
if (job && job.id !== selectedJob?.id) {
|
||
setSelectedJob(job);
|
||
setShowForm(true);
|
||
}
|
||
}
|
||
}, [jobId, isLoading, openPositions, selectedJob]);
|
||
|
||
// Auto-build a JobApplication from the worker's KROW Identity — no manual form.
|
||
const handleStartInterview = async () => {
|
||
if (!selectedJob || !profile) return;
|
||
setStarting(true);
|
||
try {
|
||
const formData = {
|
||
applicant_name: profile.full_name || '',
|
||
email: profile.email || '',
|
||
phone: profile.phone || '',
|
||
years_experience: profile.experience_years || 0,
|
||
english_level: 'basic',
|
||
certifications: profile.certifications || [],
|
||
availability: profile.availability || [],
|
||
skills: profile.skills || [],
|
||
companies_worked: (profile.experience || []).map(e => e.company).filter(Boolean),
|
||
professional_summary: profile.personality || '',
|
||
cover_letter: '',
|
||
resume_url: profile.resume_url || '',
|
||
selfie_url: profile.selfie_url || '',
|
||
certification_files: [],
|
||
};
|
||
const app = await createApplication.mutateAsync({
|
||
...formData,
|
||
job_posting_id: selectedJob.id,
|
||
job_title: selectedJob.title,
|
||
status: 'applied',
|
||
});
|
||
toast.success('Application created from your KROW Identity!');
|
||
setShowForm(false);
|
||
setInterviewApp({ ...app, job_posting_id: selectedJob.id });
|
||
} catch {
|
||
toast.error('Failed to start application');
|
||
} finally {
|
||
setStarting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="max-w-3xl mx-auto space-y-6 px-4 sm:px-0">
|
||
{/* Mobile back link */}
|
||
{showForm && (
|
||
<button onClick={() => { setShowForm(false); setSelectedJob(null); navigate('/apply', { replace: true }); }} className="text-[13px] text-[#6B7280] hover:text-[#111827] font-medium flex items-center gap-1">
|
||
<ArrowLeft className="w-3.5 h-3.5" /> {t('apply.backToPositions')}
|
||
</button>
|
||
)}
|
||
|
||
{/* Hero */}
|
||
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 sm:p-8 text-center">
|
||
<h1 className="text-[28px] font-bold text-[#111827] mb-2">{t('apply.skipTheQuizJustTalk')}</h1>
|
||
<p className="text-[14px] text-[#6B7280] max-w-lg mx-auto mb-5">
|
||
{t('apply.krowReplacesLongFormsAndTests')}
|
||
</p>
|
||
<div className="flex items-center justify-center gap-2 flex-wrap">
|
||
{[
|
||
{ icon: Clock, label: '5 minutes' },
|
||
{ icon: Mic, label: 'Voice or Text' },
|
||
{ icon: Gauge, label: 'Instant Score' },
|
||
].map(b => (
|
||
<span key={b.label} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-[#E5E7EB] text-[12px] text-[#374151] font-medium">
|
||
<b.icon className="w-3.5 h-3.5 text-[#6B7280]" />
|
||
{b.label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<button onClick={() => navigate('/owliver')} className="mt-5 inline-flex items-center gap-2 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold px-5 py-2.5 rounded-full transition-all">
|
||
{t('apply.createYourKrowIdentity')} <ChevronRight className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{showForm && selectedJob ? (
|
||
/* KROW Identity preview — auto-applies from profile, then AI interview */
|
||
<div className="space-y-4">
|
||
<ApplyIdentityCard
|
||
profile={profile}
|
||
job={selectedJob}
|
||
submitting={starting || createApplication.isPending}
|
||
onStart={handleStartInterview}
|
||
onBuildIdentity={() => navigate((profile?.profile_completion || 0) < 40 ? '/owliver' : '/identity')}
|
||
/>
|
||
|
||
{/* The candidate's own fit against this role — their skills, this
|
||
position's requirements, and nothing about anybody else. The
|
||
recruiter's ranked comparison of every candidate is the same
|
||
arithmetic and stays on the admin position page. */}
|
||
<YourSkillFit job={selectedJob} held={verifiedLevels(courses, profile || {})} />
|
||
<button onClick={() => { setShowForm(false); setSelectedJob(null); navigate('/apply', { replace: true }); }} className="text-[13px] text-[#6B7280] hover:text-[#111827] font-medium flex items-center gap-1">
|
||
<ArrowLeft className="w-3.5 h-3.5" /> {t('apply.backToPositions')}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
/* Position List */
|
||
<div>
|
||
<p className="text-[13px] font-medium text-[#374151] mb-3">{t('apply.chooseAPosition')}</p>
|
||
<div className="space-y-2">
|
||
{isLoading && [...Array(4)].map((_, i) => <div key={i} className="h-16 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />)}
|
||
{openPositions.map(job => (
|
||
<button
|
||
key={job.id}
|
||
onClick={() => { setSelectedJob(job); setShowForm(true); }}
|
||
className="w-full flex items-center gap-3 sm:gap-4 bg-white border border-[#E5E7EB] hover:border-[#D1D5DB] hover:shadow-sm rounded-xl p-4 transition-all text-left"
|
||
>
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="font-semibold text-[#111827] text-[14px] sm:text-[15px] truncate">{job.title}</h3>
|
||
<p className="text-[12px] text-[#6B7280] truncate">{job.role_category} • {job.location}</p>
|
||
<span className="sm:hidden text-[12px] font-bold text-[#111827]">${job.pay_range_min}–${job.pay_range_max}/hr</span>
|
||
</div>
|
||
<span className="hidden sm:block font-bold text-[#111827] text-[14px] whitespace-nowrap">${job.pay_range_min}–${job.pay_range_max}/hr</span>
|
||
<ChevronRight className="w-5 h-5 text-[#9CA3AF] shrink-0" />
|
||
</button>
|
||
))}
|
||
{!isLoading && openPositions.length === 0 && (
|
||
<div className="text-center py-12 text-[14px] text-[#6B7280]">{t('apply.noOpenPositionsRightNowCheck')}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* AI Interview after applying */}
|
||
{interviewApp && (
|
||
<AIInterviewModal
|
||
open={true}
|
||
onClose={() => { setInterviewApp(null); navigate('/'); }}
|
||
application={interviewApp}
|
||
job={selectedJob}
|
||
conductor="Owliver"
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
} |