Files
krow_talent_app/src/components/krow/InviteApplicantsModal.tsx
Aravind e7e1e9873f chore(ts-migration): migrate components/krow to TypeScript with real prop types
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
2026-09-18 11:39:58 +05:30

152 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}