chore(ts-migration): migrate the non-agent pages to TypeScript
Phase 9, first batch: 18 pages. Seventeen emit byte-identical JavaScript, the
eighteenth differs only by a JSDoc cast becoming a real annotation, and the
production bundle is byte-identical to be49184.
SCOPE, decided by imports. Twenty-two of the forty pages import `lib/skills`,
`lib/agents`, `ai-assistant`, `components/skills`, `ui-tree` or `ui-editor`, and
are deferred to Phase 11 with the rest of that region. `Owliver.jsx` is in THIS
batch despite the name: it imports only `krowHooks`, `krowScore`, `krowAi` and
`ui/button`, and is a profile-building conversation page rather than any part of
the agent runtime.
`DesignSystem` is the headline result. It was the single worst file in Phase 6 -
111 errors when the design system was first renamed - and it arrived here with
none, because those were never its errors: they were the primitives' inferred
props, and fixing them at the source fixed every consumer.
Three pages needed real work, and each was the author's own intent made
explicit:
- `admin/Login` carried `useState(/** @type {{email?: string, password?: string}} */ ({}))`.
JSDoc casts stop applying in a `.tsx` file, so that became a real type
argument, and `validate`'s accumulator - built empty and filled per failed
rule - needed the same shape. The login flow itself is untouched: the
generic 401 message, the 429 branch and the `remember` field all stand.
- `Owliver` gets `new Promise<void>`, because its `resolve()` takes no
argument.
- `Candidates` names the element type of a `Set` built from `any[]`, which
otherwise infers `Set<unknown>` and makes every option a `ReactNode` error.
LINT CAUGHT A REGRESSION THE OTHER CHECKS DID NOT. The generator adds an entity
import when it sees the type NAME anywhere in the file, which for four pages
with no props at all left an import nothing used - four `unused-imports` ERRORS,
taking `npm run lint` from exit 0 to exit 1 while typecheck, tests, the bundle
and seventeen of eighteen emit comparisons all stayed green. Removed. The
generator's import rule is too eager and wants narrowing before the next batch.
Verified: tsc 21 -> 21, set-difference showing zero introduced and zero removed;
zero errors in any of the 18 pages; lint back to 0 errors and 289 warnings;
npm test 1684/1691 with the same seven failures; Owliver baseline 59/59;
production bundle byte-identical; baseline artifacts untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
167
src/pages/Apply.tsx
Normal file
167
src/pages/Apply.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
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';
|
||||
|
||||
export default function Apply() {
|
||||
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" /> Back to positions
|
||||
</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">Skip the quiz. Just talk.</h1>
|
||||
<p className="text-[14px] text-[#6B7280] max-w-lg mx-auto mb-5">
|
||||
KROW replaces long forms and tests with a fast 5-minute AI interview. Speak naturally — we evaluate readiness, not memory.
|
||||
</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">
|
||||
Create your KROW Identity <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" /> Back to positions
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Position List */
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-[#374151] mb-3">Choose a position:</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]">No open positions right now. Check back soon!</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Interview after applying */}
|
||||
{interviewApp && (
|
||||
<AIInterviewModal
|
||||
open={true}
|
||||
onClose={() => { setInterviewApp(null); navigate('/'); }}
|
||||
application={interviewApp}
|
||||
job={selectedJob}
|
||||
conductor="Owliver"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user