Phase 8, first batch: 56 files, plus three supporting edits outside the folder.
All 56 emit byte-identical JavaScript and the production bundle is byte-identical
to 543da9d.
This batch establishes the pattern for the remaining component work: real prop
interfaces built from actual call sites, reusing the generated entity types.
Fifty-four components now declare what they take -
`CandidateCard({ application: JobApplication, jobTitle?: string, rank: number })`,
`MatchedCandidates({ job: JobPosting, profiles: ProfileView[], ... })` - rather
than carrying a compatibility bag. Callbacks are optional because call sites
omit them; entity props are required because call sites always pass them. Where
a call site proved otherwise, the call site won: `ScheduleInterviewModal`'s
`position`, `CandidateCard`'s `jobTitle`, `TalentDetailModal`'s `matchScore` and
`matchReasons` are optional because real callers omit them.
Two view types absorb what the registry cannot describe. `ProfileView` and
`CourseView` narrow the `jsonb` columns these components read through -
`experience`, `completed_courses`, `earned_badges`, `capabilities`,
`score_breakdown`, `challenge`, `quiz` - to arrays and objects, while every
field backed by a real column keeps the registry's type. They are declared once
for the folder, and their narrowed fields stay REQUIRED: the registry has those
columns NOT NULL, and making them optional broke assignment back to the entity
where `ChallengeRunner` hands a course to a mutation.
TWO REAL DEFECTS FOUND, BOTH PRESERVED RATHER THAN FIXED:
1. `SuggestedTalent` calls `matchTalent.mutate({...}).then(...)`. React
Query's `mutate` returns `void`, so that `.then` throws at run time, and
`runMatch` is reachable from a button. `mutateAsync` is what the code
means. Swapping it turns a crash into a working feature, which is a
product change, not a migration one - so it is cast to compile and left
behaving exactly as it did. This one deserves a fix on its own terms.
2. `TalentPoolCard` renders a location row behind `profile.location &&`, but
`worker_profiles` has no `location` column - the registry has none and the
API cannot send one, so the row has never rendered. Recorded as an
optional field on `ProfileView` with a note, the same treatment as
`user.avatar_url` in Phase 7.
Also fixed, all type-only: the Web Speech API declared as the optional `Window`
members `AIInterviewModal` already feature-detects; four `new Promise<void>`
where `resolve()` takes no argument; `toast`'s options bag made optional on
every method, which callers had always omitted; five `krowHooks` query
arguments made optional, which callers had always omitted.
One automated pass was reverted rather than shipped. A local-component
annotator captured words out of the preceding JSDoc as prop names - producing
`interface FrameProps { one?: any; every?: any }` from a sentence about
"one surface, one padding, every step" - the same class of regex error as
Phase 6's. The whole folder was restored from the index and the sound steps
re-run, then local components were annotated with an index-spliced rewrite that
reads only the destructuring.
Verified: tsc 35 -> 22, set-difference showing thirteen removed and none added;
zero errors in any of the 56 files; all 56 emit byte-identical JavaScript;
production bundle byte-identical; npm test 1684/1691 with the same seven
failures; Owliver baseline 59/59; lint 0 errors; baseline artifacts untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
152 lines
6.2 KiB
TypeScript
152 lines
6.2 KiB
TypeScript
import type { JobPosting } from '@/types/entities';
|
||
import React, { useState, useMemo } from 'react';
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Copy, Check, Share2, Mail, Link2, Smartphone, Users } from 'lucide-react';
|
||
import { toast } from 'react-hot-toast';
|
||
|
||
interface InviteApplicantsModalProps {
|
||
open: boolean;
|
||
onClose?: () => void;
|
||
job: JobPosting;
|
||
}
|
||
|
||
export default function InviteApplicantsModal({ open, onClose, job }: InviteApplicantsModalProps) {
|
||
const [copied, setCopied] = useState(false);
|
||
const [emailRecipients, setEmailRecipients] = useState('');
|
||
const [sending, setSending] = useState(false);
|
||
|
||
const shareUrl = useMemo(() => {
|
||
if (!job?.id) return '';
|
||
const base = window.location.origin;
|
||
return `${base}/apply?job=${job.id}`;
|
||
}, [job?.id]);
|
||
|
||
const jobTitle = job?.title || 'this position';
|
||
|
||
const handleCopyLink = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(shareUrl);
|
||
setCopied(true);
|
||
toast.success('Invitation link copied!');
|
||
setTimeout(() => setCopied(false), 2000);
|
||
} catch {
|
||
toast.error('Could not copy link');
|
||
}
|
||
};
|
||
|
||
const handleNativeShare = async () => {
|
||
if (navigator.share) {
|
||
try {
|
||
await navigator.share({
|
||
title: `Apply: ${jobTitle} at KROW`,
|
||
text: `You've been invited to apply for ${jobTitle}. Complete a quick 5-minute AI interview — no forms, no quizzes.`,
|
||
url: shareUrl,
|
||
});
|
||
} catch { /* user cancelled */ }
|
||
} else {
|
||
handleCopyLink();
|
||
}
|
||
};
|
||
|
||
const handleEmailInvite = () => {
|
||
const recipients = emailRecipients.trim();
|
||
if (!recipients) return;
|
||
const subject = `You're invited to apply: ${jobTitle}`;
|
||
const body = `Hi,\n\nYou've been invited to apply for ${jobTitle} at KROW.\n\nClick the link below to view the position and complete a quick 5-minute AI interview:\n\n${shareUrl}\n\nNo long forms, no quizzes — just speak naturally and get instant feedback.\n\nKROW Talent Engine`;
|
||
const mailtoUrl = `mailto:${recipients}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||
window.location.href = mailtoUrl;
|
||
setSending(true);
|
||
setTimeout(() => {
|
||
setSending(false);
|
||
toast.success('Email invitation opened');
|
||
}, 500);
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Users className="w-5 h-5 text-[#0838E0]" />
|
||
Invite Applicants
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-5">
|
||
{/* Job info */}
|
||
<div className="bg-[#F8FAFC] rounded-xl p-3 border border-[#E5E7EB]">
|
||
<p className="text-[13px] font-semibold text-[#111827]">{jobTitle}</p>
|
||
{job?.location && <p className="text-[12px] text-[#6B7280]">{job.location}</p>}
|
||
{job?.pay_range_min != null && (
|
||
<p className="text-[12px] text-[#0838E0] font-medium">${job.pay_range_min}–${job.pay_range_max}/hr</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Shareable Link */}
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[#374151] flex items-center gap-1.5 mb-2">
|
||
<Link2 className="w-3.5 h-3.5" /> Invitation Link
|
||
</label>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
readOnly
|
||
value={shareUrl}
|
||
className="flex-1 min-w-0 px-3 py-2 text-[12px] rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] text-[#6B7280] truncate"
|
||
/>
|
||
<button
|
||
onClick={handleCopyLink}
|
||
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-[#0838E0] text-white text-[12px] font-medium hover:bg-[#062BAF] transition-colors"
|
||
>
|
||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||
{copied ? 'Copied' : 'Copy'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Native Share (mobile) */}
|
||
<button
|
||
onClick={handleNativeShare}
|
||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-[#E5E7EB] text-[13px] font-medium text-[#374151] hover:bg-[#F9FAFB] transition-colors"
|
||
>
|
||
<Share2 className="w-4 h-4" />
|
||
Share via...
|
||
</button>
|
||
|
||
{/* Email Invite */}
|
||
<div>
|
||
<label className="text-[12px] font-medium text-[#374151] flex items-center gap-1.5 mb-2">
|
||
<Mail className="w-3.5 h-3.5" /> Email Invitation
|
||
</label>
|
||
<textarea
|
||
value={emailRecipients}
|
||
onChange={(e) => setEmailRecipients(e.target.value)}
|
||
placeholder="applicant1@email.com, applicant2@email.com"
|
||
className="w-full min-h-[48px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
|
||
/>
|
||
<button
|
||
onClick={handleEmailInvite}
|
||
disabled={!emailRecipients.trim() || sending}
|
||
className="w-full mt-2 inline-flex items-center justify-center gap-2 py-2.5 rounded-lg bg-[#0838E0] text-white text-[13px] font-medium hover:bg-[#062BAF] disabled:opacity-50 transition-colors"
|
||
>
|
||
<Mail className="w-4 h-4" />
|
||
Send Email Invitations
|
||
</button>
|
||
</div>
|
||
|
||
{/* Mobile app note */}
|
||
<div className="flex items-start gap-2 p-3 bg-[#EEF3FE] rounded-lg">
|
||
<Smartphone className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
|
||
<p className="text-[11px] text-[#374151] leading-relaxed">
|
||
Applicants open the link on any phone — no app download needed. They see open positions and complete the AI interview right in their mobile browser.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={onClose} className="w-full">Done</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
} |