HR-ATS-Portal/frontend/src/screens/Talent.jsx

837 lines
32 KiB
JavaScript

/* ============================================================
Talent — LinkedIn talent sourcing per job (backend/talent/, Apify).
Pick a job, start a paid actor search, watch the run, browse the profiles.
The status poll is what persists results server-side: the backend fetches
the Apify dataset the first time it sees the run SUCCEEDED, so reloading
mid-run loses nothing — the screen re-adopts the newest unfinished run and
keeps polling. Profiles are deduped per job across re-runs by LinkedIn URL.
============================================================ */
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, Icon } from '../ui/primitives'
import { Tabs } from '../ui/Tabs'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as talentApi from '../api/talent'
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
const RUN_BADGE = {
pending: ['b-blue', 'Starting…'],
running: ['b-blue', 'Sourcing…'],
succeeded: ['b-green', 'Completed'],
failed: ['b-red', 'Failed'],
timed_out: ['b-red', 'Timed out'],
aborted: ['b-amber', 'Aborted'],
}
/* Manual outreach funnel: sourced -> shortlisted -> contacted, one-step undo.
The app sends nothing — "contacted" records that the recruiter messaged the
person on LinkedIn themselves. Mirror of backend/talent/enums.py. */
const OUTREACH_TOAST = {
shortlisted: 'Added to shortlist',
contacted: 'Marked as contacted',
sourced: 'Removed from shortlist',
}
/* Apify money is in fractional dollars ($0.008/profile), so seed.js's
whole-dollar money() helper is the wrong tool here. */
const fmtUsd = (n, digits = 2) => (n == null ? '—' : `$${n.toFixed(digits)}`)
async function fetchJobs() {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: row.id, title: row.title, location: row.location }))
}
/* Where to source from. Pakistan is the company's hub (Karachi and Lahore
offices today), so those lead the list; "Anywhere" clears the geography
filter server-side (useful for remote roles); CUSTOM reveals a free-text
input for anything else. */
const CUSTOM_LOCATION = '__custom__'
const LOCATION_OPTIONS = [
{ value: 'Karachi, Pakistan', label: 'Karachi' },
{ value: 'Lahore, Pakistan', label: 'Lahore' },
{ value: 'Pakistan', label: 'Pakistan — country-wide' },
{ value: 'Anywhere', label: 'Anywhere (no location filter)' },
{ value: CUSTOM_LOCATION, label: 'Custom location…' },
]
/* Work arrangements are not geographies — mirror of the backend list. */
const NON_GEOGRAPHIC = new Set([
'remote', 'hybrid', 'onsite', 'on-site', 'on site',
'anywhere', 'flexible', 'wfh', 'work from home',
])
/** Dropdown default for a job: its own city when it has one, else the hub. */
function defaultLocationFor(job) {
const loc = (job?.location || '').trim()
if (!loc || NON_GEOGRAPHIC.has(loc.toLowerCase())) {
// Remote/unspecified posts still source from the hub by default; the
// recruiter can widen to country-wide or Anywhere from the dropdown.
return { choice: 'Pakistan', custom: '' }
}
const match = LOCATION_OPTIONS.find(
(o) => o.value !== CUSTOM_LOCATION && o.value.toLowerCase().startsWith(loc.toLowerCase()),
)
if (match) return { choice: match.value, custom: '' }
return { choice: CUSTOM_LOCATION, custom: loc }
}
function ProfileAvatar({ name, url }) {
const [broken, setBroken] = useState(false)
if (url && !broken) {
return (
<img
className="avatar avatar-lg"
src={url}
alt={name || 'Profile photo'}
referrerPolicy="no-referrer"
style={{ objectFit: 'cover', padding: 0 }}
onError={() => setBroken(true)}
/>
)
}
return (
<span className="avatar avatar-lg" style={{ background: avatarColor(name || '?') }}>
{initialsOf(name || '?')}
</span>
)
}
/** Big centered loader: the ai-assist ring scaled up inline (CSS is frozen). */
function BigLoader({ title, children }) {
return (
<div style={{ display: 'grid', placeItems: 'center', padding: '64px 20px', textAlign: 'center' }}>
<span
className="ai-assist-spinner"
role="status"
aria-label={title}
style={{ width: 72, height: 72, borderWidth: 5 }}
/>
<div className="fw-600" style={{ marginTop: 20, fontSize: 17 }}>{title}</div>
{children && <p className="text-muted" style={{ marginTop: 6, maxWidth: 420 }}>{children}</p>}
</div>
)
}
/** The JobCandidates MiniRing verbatim, fed by the deterministic job-match score. */
function MatchRing({ score, size = 46 }) {
if (score == null) return null
const color = score >= 70 ? 'var(--success)' : score >= 40 ? 'var(--warning)' : 'var(--danger)'
return (
<div
data-tip="Job match"
style={{
width: size, height: size, borderRadius: '50%', flexShrink: 0,
display: 'grid', placeItems: 'center',
background: `conic-gradient(${color} ${score}%, var(--bg-sunken) 0)`,
}}
>
<div
style={{
width: size - 8, height: size - 8, borderRadius: '50%',
background: 'var(--bg-elev)', display: 'grid', placeItems: 'center',
fontWeight: 700, fontSize: 13.5, letterSpacing: '-0.01em',
color,
}}
>
{score}
</div>
</div>
)
}
/**
* "Already applied" chip: shown when a CV in the ATS carries this profile's
* /in/<slug> link. Green when they applied to THIS job (sourcing them again
* wastes an InMail); amber when the CV came in against a different job.
*/
function AppliedBadge({ applied }) {
if (!applied) return null
const label = applied.same_job ? 'Already applied' : 'In ATS · other job'
const tip = [
applied.candidate,
applied.status ? `status ${applied.status}` : null,
applied.applied_at ? `applied ${fmtDate(applied.applied_at)}` : null,
applied.applications > 1 ? `${applied.applications} applications` : null,
].filter(Boolean).join(' · ')
return (
<Badge className={applied.same_job ? 'b-green' : 'b-amber'} data-tip={tip || undefined}>
<Icon name="check-circle" /> {label}
</Badge>
)
}
/** Card/modal-shared outreach controls: star = shortlist toggle, check = contacted. */
function outreachProps(p) {
const s = p.outreachStatus
return {
star: {
shown: true,
active: s !== 'sourced',
disabled: s === 'contacted',
next: s === 'sourced' ? 'shortlisted' : 'sourced',
tip:
s === 'sourced' ? 'Shortlist for outreach'
: s === 'shortlisted' ? 'Shortlisted — click to remove'
: 'Undo Contacted first to un-shortlist',
},
check: {
shown: s !== 'sourced',
active: s === 'contacted',
next: s === 'shortlisted' ? 'contacted' : 'shortlisted',
tip:
s === 'shortlisted'
? 'Mark contacted (after messaging on LinkedIn)'
: `Contacted ${p.contactedAt ? fmtDate(p.contactedAt) : ''}${p.contactedByName ? ` by ${p.contactedByName}` : ''} — click to undo`,
},
}
}
function ProfileCard({ p, onView, onDismiss, dismissing, canEdit, onOutreach, outreachBusy, showContacted }) {
const crit = p.summary || p.headline || ''
const shown = p.skills.slice(0, 5)
const more = p.skills.length - shown.length
return (
<div className="card cand-card" onClick={() => onView(p)}>
<div className="card-body">
<div className="cand-head">
<ProfileAvatar name={p.name} url={p.avatarUrl} />
<div className="cand-id">
<div className="cand-name">{p.name ?? 'Unknown'}</div>
<div className="cand-role">{p.currentTitle ?? p.headline ?? '—'}</div>
<AppliedBadge applied={p.alreadyApplied} />
</div>
<MatchRing score={p.matchScore} />
</div>
{shown.length > 0 && (
<div className="cand-skills">
{shown.map((s) => <span className="cand-chip" key={s}>{s}</span>)}
{more > 0 && <span className="cand-chip more">+{more} more</span>}
</div>
)}
<p className="cand-crit">{crit}</p>
<div className="cand-foot">
<span className="cand-meta"><Icon name="map" /> {p.location ?? '—'}</span>
<span className="cand-company">{p.currentCompany ?? ''}</span>
<a
className="act-btn"
data-tip="Open LinkedIn profile"
aria-label="Open LinkedIn profile"
href={p.linkedinUrl}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
>
<Icon name="linkedin" />
</a>
<button
className="act-btn"
data-tip="View profile"
aria-label="View profile"
onClick={(e) => { e.stopPropagation(); onView(p) }}
>
<Icon name="eye" />
</button>
{canEdit && (() => {
const o = outreachProps(p)
return (
<>
<button
className="act-btn"
data-tip={o.star.tip}
aria-label={o.star.tip}
disabled={outreachBusy || o.star.disabled}
style={o.star.active ? { color: 'var(--warning)' } : undefined}
onClick={(e) => { e.stopPropagation(); onOutreach(p, o.star.next) }}
>
<Icon name="star" />
</button>
{o.check.shown && showContacted && (
<button
className="act-btn"
data-tip={o.check.tip}
aria-label={o.check.tip}
disabled={outreachBusy}
style={o.check.active ? { color: 'var(--success)' } : undefined}
onClick={(e) => { e.stopPropagation(); onOutreach(p, o.check.next) }}
>
<Icon name="check-circle" />
</button>
)}
</>
)
})()}
<button
className="act-btn"
data-tip="Dismiss (removes from all tabs)"
aria-label="Dismiss profile"
disabled={dismissing}
onClick={(e) => { e.stopPropagation(); onDismiss(p) }}
>
<Icon name="trash" />
</button>
</div>
</div>
</div>
)
}
/** Full LinkedIn profile: hero + about + skills + employment/education history. */
function TalentProfileDetail({ profileId, onClose, canEdit, onOutreach, outreachBusy }) {
const detailQuery = useQuery({
queryKey: qk.talent.profile(profileId),
queryFn: () => talentApi.getProfile(profileId),
})
const p = detailQuery.data?.data ? talentApi.toProfileDetailView(detailQuery.data.data) : null
const o = p ? outreachProps(p) : null
return (
<Modal
title="Talent Profile"
subtitle={p?.headline ?? undefined}
size="modal-lg"
onClose={onClose}
footer={
<>
{p && canEdit && (
<>
<button
className="btn"
data-tip={o.star.tip}
disabled={outreachBusy || o.star.disabled}
onClick={() => onOutreach(p, o.star.next)}
>
<Icon name="star" />
{p.outreachStatus === 'sourced' ? 'Shortlist' : 'Un-shortlist'}
</button>
{o.check.shown && (
<button
className="btn"
data-tip={o.check.tip}
disabled={outreachBusy}
onClick={() => onOutreach(p, o.check.next)}
>
<Icon name="check-circle" />
{p.outreachStatus === 'contacted' ? 'Undo contacted' : 'Mark contacted'}
</button>
)}
</>
)}
{p && (
<a className="btn btn-primary" href={p.linkedinUrl} target="_blank" rel="noreferrer">
<Icon name="linkedin" /> Open LinkedIn
</a>
)}
<button className="btn" onClick={onClose}>Close</button>
</>
}
>
{detailQuery.isError ? (
<EmptyState icon="alert" title="Could not load this profile">
{friendlyAuthError(detailQuery.error, 'Please try again.')}
</EmptyState>
) : detailQuery.isPending ? (
<BigLoader title="Loading profile…" />
) : (
<>
<div className="profile-hero">
<ProfileAvatar name={p.name} url={p.avatarUrl} />
<div style={{ flex: 1 }}>
<div className="ph-name">{p.name ?? 'Unknown'}</div>
<div className="ph-role">
{[p.currentTitle, p.currentCompany].filter(Boolean).join(' at ') || p.headline || '—'}
</div>
<div className="ph-tags">
{p.location && <Badge className="b-plain b-indigo badge-plain">{p.location}</Badge>}
<Badge className="b-gray">LinkedIn</Badge>
<AppliedBadge applied={p.alreadyApplied} />
{p.shortlistedAt && (
<Badge className="b-amber">
<Icon name="star" /> Shortlisted {fmtDate(p.shortlistedAt)}
{p.shortlistedByName ? ` by ${p.shortlistedByName}` : ''}
</Badge>
)}
{p.contactedAt && (
<Badge className="b-green">
<Icon name="check-circle" /> Contacted {fmtDate(p.contactedAt)}
{p.contactedByName ? ` by ${p.contactedByName}` : ''}
</Badge>
)}
{p.lastSeenAt && (
<Badge className="b-plain b-indigo badge-plain">Found {fmtDate(p.lastSeenAt)}</Badge>
)}
</div>
</div>
</div>
{p.summary && (
<>
<h3 className="form-section-title">About</h3>
<p className="text-muted" style={{ whiteSpace: 'pre-line' }}>{p.summary}</p>
</>
)}
{p.skills.length > 0 && (
<>
<h3 className="form-section-title">Skills ({p.skills.length})</h3>
<div className="k-tags" style={{ marginBottom: 16 }}>
{p.skills.map((s) => <span className="tag" key={s}>{s}</span>)}
</div>
</>
)}
{p.experience.length > 0 && (
<>
<h3 className="form-section-title">Experience ({p.experience.length})</h3>
{p.experience.map((e, i) => (
<div key={i} style={{ marginBottom: 14 }}>
<div className="fw-600">
{[e.title, e.company].filter(Boolean).join(' — ') || '—'}
</div>
<div className="text-muted text-sm">
{[e.period, e.duration, e.employmentType, e.location].filter(Boolean).join(' · ')}
</div>
{e.description && (
<p className="text-muted text-sm" style={{ marginTop: 4 }}>{e.description}</p>
)}
{e.skills.length > 0 && (
<div className="cand-skills" style={{ marginTop: 6 }}>
{e.skills.map((s) => <span className="cand-chip" key={s}>{s}</span>)}
</div>
)}
</div>
))}
</>
)}
{p.education.length > 0 && (
<>
<h3 className="form-section-title">Education ({p.education.length})</h3>
{p.education.map((e, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<div className="fw-600">{e.school ?? '—'}</div>
<div className="text-muted text-sm">
{[[e.degree, e.field].filter(Boolean).join(', '), e.period].filter(Boolean).join(' · ')}
</div>
</div>
))}
</>
)}
</>
)}
</Modal>
)
}
export default function Talent() {
const { toast } = useToast()
const qc = useQueryClient()
const { can } = useAuth()
const canEdit = can('talent.edit')
const [jobId, setJobId] = useState('')
const [tab, setTab] = useState('all')
const [activeRunId, setActiveRunId] = useState(null)
const [confirmOpen, setConfirmOpen] = useState(false)
const [search, setSearch] = useState('')
const [locationChoice, setLocationChoice] = useState('Pakistan')
const [customLocation, setCustomLocation] = useState('')
const [visibleCount, setVisibleCount] = useState(10)
const [viewProfileId, setViewProfileId] = useState(null)
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
const jobs = jobsQuery.data ?? []
const selectedJob = jobs.find((j) => j.id === jobId)
// Apify account money for the header chips. Keyed under ['talent', ...], so
// the settle-once effect's invalidation refreshes it after every search.
const accountQuery = useQuery({
queryKey: qk.talent.account(),
queryFn: talentApi.getAccount,
})
const account = accountQuery.data?.data
? talentApi.toAccountView(accountQuery.data.data)
: null
const runsQuery = useQuery({
queryKey: qk.talent.runs(jobId),
queryFn: () => talentApi.listRuns(jobId),
enabled: !!jobId,
})
const runs = useMemo(
() => (Array.isArray(runsQuery.data?.data) ? runsQuery.data.data.map(talentApi.toRunView) : []),
[runsQuery.data],
)
const latestRun = runs[0] ?? null
// Resume-after-reload: adopt the newest unfinished run as the poll target.
useEffect(() => {
if (!activeRunId && latestRun && !latestRun.isTerminal) setActiveRunId(latestRun.id)
}, [activeRunId, latestRun])
const statusQuery = useQuery({
queryKey: qk.talent.run(activeRunId),
queryFn: () => talentApi.getRunStatus(activeRunId),
enabled: !!activeRunId,
refetchInterval: (query) => {
const status = query.state.data?.data?.status
return status && talentApi.isTerminalRun(status) ? false : 4000
},
})
const activeRun = statusQuery.data?.data ? talentApi.toRunView(statusQuery.data.data) : null
const runInFlight = !!activeRun && !activeRun.isTerminal
// Toast + refresh exactly once per run settling.
const settledRef = useRef(null)
useEffect(() => {
if (!activeRun || !activeRun.isTerminal || settledRef.current === activeRun.id) return
settledRef.current = activeRun.id
qc.invalidateQueries({ queryKey: qk.talent.all() })
setVisibleCount(10)
if (activeRun.status === 'succeeded') {
toast(`${activeRun.profilesFound} profile${activeRun.profilesFound === 1 ? '' : 's'} found on LinkedIn`, 'success')
} else {
toast(activeRun.error || `Talent search ${activeRun.status.replace('_', ' ')}`, 'error')
}
}, [activeRun, qc, toast])
const profilesQuery = useQuery({
queryKey: qk.talent.profiles({ jobId }),
queryFn: () => talentApi.listProfiles({ jobId }),
enabled: !!jobId,
})
const profiles = useMemo(
() =>
Array.isArray(profilesQuery.data?.data)
? profilesQuery.data.data.map(talentApi.toProfileView)
: [],
[profilesQuery.data],
)
const counts = useMemo(() => ({
all: profiles.length,
shortlisted: profiles.filter((p) => p.outreachStatus === 'shortlisted').length,
contacted: profiles.filter((p) => p.outreachStatus === 'contacted').length,
}), [profiles])
const tabs = [
{ key: 'all', label: 'All', count: counts.all },
{ key: 'shortlisted', label: 'Shortlisted', count: counts.shortlisted },
{ key: 'contacted', label: 'Contacted', count: counts.contacted },
]
const visible = useMemo(() => {
const scoped = tab === 'all' ? profiles : profiles.filter((p) => p.outreachStatus === tab)
const q = search.trim().toLowerCase()
if (!q) return scoped
return scoped.filter((p) =>
[p.name, p.headline, p.currentCompany, p.currentTitle, p.location]
.some((f) => f && f.toLowerCase().includes(q)),
)
}, [profiles, search, tab])
const effectiveLocation =
locationChoice === CUSTOM_LOCATION ? customLocation.trim() : locationChoice
const locationLabel =
locationChoice === 'Anywhere'
? 'anywhere (no location filter)'
: `in ${effectiveLocation}`
const starting = useMutation({
mutationFn: () => talentApi.startRun(jobId, { location: effectiveLocation }),
onSuccess: (res) => {
setConfirmOpen(false)
const run = res?.data
if (run?.id) {
qc.setQueryData(qk.talent.run(run.id), res)
setActiveRunId(run.id)
}
qc.invalidateQueries({ queryKey: qk.talent.runs(jobId) })
// The settle effect refreshes the money chips when the run finishes;
// this catches the spend of the run that just started sooner.
qc.invalidateQueries({ queryKey: qk.talent.account() })
toast('Talent search started', 'success')
},
onError: (err) => {
setConfirmOpen(false)
toast(friendlyAuthError(err, 'Could not start the talent search'), 'error')
},
})
const dismissing = useMutation({
mutationFn: (profile) => talentApi.deleteProfile(profile.id),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.talent.profiles({ jobId }) }),
onError: (err) => toast(friendlyAuthError(err, 'Could not dismiss the profile'), 'error'),
})
const outreach = useMutation({
mutationFn: ({ id, status }) => talentApi.setOutreachStatus(id, status),
onSuccess: (_res, vars) => {
qc.invalidateQueries({ queryKey: qk.talent.profiles({ jobId }) })
qc.invalidateQueries({ queryKey: qk.talent.profile(vars.id) })
toast(OUTREACH_TOAST[vars.status] ?? 'Updated', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update outreach status'), 'error'),
})
const handleOutreach = (profile, status) => outreach.mutate({ id: profile.id, status })
const statusRun = runInFlight || !latestRun ? activeRun : latestRun
const [badgeCls, badgeLabel] = statusRun ? (RUN_BADGE[statusRun.status] ?? ['b-gray', statusRun.status]) : []
return (
<div className="page">
<PageHeader
title="Find Talent"
sub="Source matching LinkedIn profiles for a job via Apify"
actions={
<>
<span className="integration-status pending"><span className="pulse" />LinkedIn Sourcing · Live</span>
{account && (
<>
<span
className="billing-chip"
data-tip={
account.monthlyLimitUsd != null
? `Remaining of the ${fmtUsd(account.monthlyLimitUsd)} Apify monthly limit`
: 'Apify is unreachable right now'
}
>
Balance <strong>{fmtUsd(account.balanceUsd)}</strong>
</span>
<span
className="billing-chip"
data-tip={
account.cycleEndsAt
? `Apify spend this billing cycle · resets ${fmtDate(account.cycleEndsAt)}`
: 'Apify spend this billing cycle'
}
>
Spent <strong>{fmtUsd(account.spentUsd)}</strong>
</span>
<span
className="billing-chip"
data-tip={
account.costPerProfileUsd != null
? `Average over ${account.totalProfilesFound} sourced profiles (${fmtUsd(account.totalCostUsd)} total)`
: 'Shown after the first search records its cost'
}
>
<strong>{fmtUsd(account.costPerProfileUsd, 3)}</strong>/profile
</span>
</>
)}
</>
}
/>
<div className="card mb-18">
<div className="card-body">
<div className="talent-controls">
<select
className="select tc-job"
aria-label="Source for job"
value={jobId}
onChange={(e) => {
const nextId = e.target.value
setJobId(nextId)
setTab('all')
setActiveRunId(null)
setSearch('')
setVisibleCount(10)
const preset = defaultLocationFor(jobs.find((j) => j.id === nextId))
setLocationChoice(preset.choice)
setCustomLocation(preset.custom)
}}
>
<option value="">Select a job post</option>
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
</select>
<select
className="select tc-loc"
value={locationChoice}
onChange={(e) => setLocationChoice(e.target.value)}
aria-label="Location"
>
{LOCATION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{locationChoice === CUSTOM_LOCATION && (
<input
className="input tc-custom"
placeholder="City or country…"
value={customLocation}
onChange={(e) => setCustomLocation(e.target.value)}
/>
)}
<button
className="btn btn-primary"
disabled={!jobId || runInFlight || starting.isPending || !effectiveLocation}
onClick={() => setConfirmOpen(true)}
>
<Icon name={runInFlight ? 'clock' : 'search'} />
{runInFlight ? 'Sourcing…' : 'Find Talent'}
</button>
</div>
{jobsQuery.isError && (
<p className="text-muted text-sm" style={{ marginTop: 12 }}>
{friendlyAuthError(jobsQuery.error, 'Could not load job posts')}
</p>
)}
{jobId && statusRun && (
<p className="text-muted text-sm flex items-center gap-8 flex-wrap" style={{ marginTop: 12 }}>
<Badge className={badgeCls}>{badgeLabel}</Badge>
{statusRun.status === 'succeeded' && (
<span>{statusRun.profilesFound} profile{statusRun.profilesFound === 1 ? '' : 's'} in the last run</span>
)}
{statusRun.costUsd > 0 && <span>· cost {fmtUsd(statusRun.costUsd)}</span>}
{statusRun.error && <span>{statusRun.error}</span>}
{statusRun.createdAt && <span>· {fmtDate(statusRun.createdAt)}</span>}
</p>
)}
</div>
</div>
{!jobId ? (
<EmptyState icon="user-plus" title="Pick a job to source for">
Sourced LinkedIn profiles are saved per job and kept across searches.
</EmptyState>
) : profilesQuery.isError ? (
<EmptyState icon="alert" title="Could not load sourced profiles">
{friendlyAuthError(profilesQuery.error, 'Please try again.')}
</EmptyState>
) : profilesQuery.isPending ? (
<BigLoader title="Loading sourced profiles…" />
) : profiles.length === 0 ? (
runInFlight ? (
<BigLoader title="Searching LinkedIn…">
Scanning profiles matching this job&apos;s title, skills and experience.
This usually takes a minute or two results appear here automatically.
</BigLoader>
) : (
<EmptyState icon="search" title="No profiles sourced yet">
Run Find Talent to search LinkedIn for people matching this job.
</EmptyState>
)
) : (
<>
<Tabs
className="tabs tabs-wrap"
value={tab}
onChange={(t) => { setTab(t); setVisibleCount(10) }}
tabs={tabs}
/>
<div className="flex items-center gap-8 flex-wrap mb-18" style={{ marginTop: 12 }}>
<div className="toolbar-search">
<Icon name="search" />
<input
placeholder="Filter by name, headline, company…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<span className="text-muted text-sm">
{visible.length} of {profiles.length} profile{profiles.length === 1 ? '' : 's'}
</span>
</div>
{visible.length === 0 && tab !== 'all' && !search.trim() ? (
tab === 'shortlisted' ? (
<EmptyState icon="star" title="No shortlisted profiles yet">
Star a profile in the All tab to build your outreach list.
</EmptyState>
) : (
<EmptyState icon="check-circle" title="No one marked contacted yet">
After messaging a shortlisted person on LinkedIn, mark them contacted
so the team knows they have been reached.
</EmptyState>
)
) : (
<div className="grid g-3">
{visible.slice(0, visibleCount).map((p) => (
<ProfileCard
key={p.id}
p={p}
onView={(profile) => setViewProfileId(profile.id)}
onDismiss={(profile) => dismissing.mutate(profile)}
dismissing={dismissing.isPending}
canEdit={canEdit}
onOutreach={handleOutreach}
outreachBusy={outreach.isPending}
showContacted={tab !== 'all'}
/>
))}
</div>
)}
<div className="flex items-center gap-8" style={{ justifyContent: 'center', marginTop: 18 }}>
{visible.length > visibleCount ? (
<button className="btn" onClick={() => setVisibleCount((n) => n + 10)}>
<Icon name="chevron-down" />
Show more ({visible.length - visibleCount} remaining)
</button>
) : tab === 'all' ? (
<button
className="btn"
disabled={runInFlight || starting.isPending}
onClick={() => setConfirmOpen(true)}
>
<Icon name={runInFlight ? 'clock' : 'search'} />
{runInFlight ? 'Sourcing…' : 'Search LinkedIn for more'}
</button>
) : null}
</div>
</>
)}
{viewProfileId && (
<TalentProfileDetail
profileId={viewProfileId}
onClose={() => setViewProfileId(null)}
canEdit={canEdit}
onOutreach={handleOutreach}
outreachBusy={outreach.isPending}
/>
)}
{confirmOpen && (
<Modal
title="Start LinkedIn talent search"
subtitle={selectedJob?.title}
onClose={() => setConfirmOpen(false)}
footer={
<>
<button className="btn" onClick={() => setConfirmOpen(false)}>Cancel</button>
<button
className="btn btn-primary"
disabled={starting.isPending}
onClick={() => starting.mutate()}
>
{starting.isPending ? 'Starting…' : 'Start search'}
</button>
</>
}
>
<p>
This starts a <strong>paid</strong> Apify search of LinkedIn for people matching
this job&apos;s title, technical requirements and experience level, {locationLabel}
up to 25 profiles per run (
{account?.costPerProfileUsd != null
? `${fmtUsd(account.costPerProfileUsd * 25)} at your average of ${fmtUsd(account.costPerProfileUsd, 3)}/profile`
: 'roughly $0.20'}
). Repeating the same search continues deeper into the results, so each run
surfaces new people; anyone already found is refreshed, not duplicated.
</p>
</Modal>
)}
</div>
)
}