622 lines
23 KiB
JavaScript
622 lines
23 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 { Badge, EmptyState, Icon } from '../ui/primitives'
|
|
import { useToast } from '../ui/Toast'
|
|
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'],
|
|
}
|
|
|
|
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: 800, fontSize: 13.5, letterSpacing: '-.3px',
|
|
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 ${new Date(applied.applied_at).toLocaleDateString()}` : 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>
|
|
)
|
|
}
|
|
|
|
function ProfileCard({ p, onView, onDismiss, dismissing }) {
|
|
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"
|
|
href={p.linkedinUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Icon name="linkedin" />
|
|
</a>
|
|
<button
|
|
className="act-btn"
|
|
data-tip="View profile"
|
|
onClick={(e) => { e.stopPropagation(); onView(p) }}
|
|
>
|
|
<Icon name="eye" />
|
|
</button>
|
|
<button
|
|
className="act-btn"
|
|
data-tip="Dismiss"
|
|
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 }) {
|
|
const detailQuery = useQuery({
|
|
queryKey: qk.talent.profile(profileId),
|
|
queryFn: () => talentApi.getProfile(profileId),
|
|
})
|
|
const p = detailQuery.data?.data ? talentApi.toProfileDetailView(detailQuery.data.data) : null
|
|
|
|
return (
|
|
<Modal
|
|
title="Talent Profile"
|
|
subtitle={p?.headline ?? undefined}
|
|
size="modal-lg"
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
{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.lastSeenAt && (
|
|
<Badge className="b-plain b-indigo badge-plain">Found {fmtDate(p.lastSeenAt)}</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{p.summary && (
|
|
<>
|
|
<div className="form-section-title">About</div>
|
|
<p className="text-muted" style={{ whiteSpace: 'pre-line' }}>{p.summary}</p>
|
|
</>
|
|
)}
|
|
|
|
{p.skills.length > 0 && (
|
|
<>
|
|
<div className="form-section-title">Skills ({p.skills.length})</div>
|
|
<div className="k-tags" style={{ marginBottom: 16 }}>
|
|
{p.skills.map((s) => <span className="tag" key={s}>{s}</span>)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{p.experience.length > 0 && (
|
|
<>
|
|
<div className="form-section-title">Experience ({p.experience.length})</div>
|
|
{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 && (
|
|
<>
|
|
<div className="form-section-title">Education ({p.education.length})</div>
|
|
{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 [jobId, setJobId] = useState('')
|
|
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)
|
|
|
|
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 visible = useMemo(() => {
|
|
const q = search.trim().toLowerCase()
|
|
if (!q) return profiles
|
|
return profiles.filter((p) =>
|
|
[p.name, p.headline, p.currentCompany, p.currentTitle, p.location]
|
|
.some((f) => f && f.toLowerCase().includes(q)),
|
|
)
|
|
}, [profiles, search])
|
|
|
|
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) })
|
|
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 statusRun = runInFlight || !latestRun ? activeRun : latestRun
|
|
const [badgeCls, badgeLabel] = statusRun ? (RUN_BADGE[statusRun.status] ?? ['b-gray', statusRun.status]) : []
|
|
|
|
return (
|
|
<div className="page">
|
|
<div className="page-head">
|
|
<div>
|
|
<h1 className="page-title">Find Talent</h1>
|
|
<p className="page-sub">Source matching LinkedIn profiles for a job via Apify</p>
|
|
</div>
|
|
<div className="page-head-actions">
|
|
<span className="integration-status pending"><span className="pulse" />LinkedIn Sourcing · Live</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card mb-18">
|
|
<div className="card-body">
|
|
<div className="flex items-center gap-8" style={{ flexWrap: 'wrap' }}>
|
|
<select
|
|
className="select"
|
|
style={{ flex: 1, minWidth: 180 }}
|
|
aria-label="Source for job"
|
|
value={jobId}
|
|
onChange={(e) => {
|
|
const nextId = e.target.value
|
|
setJobId(nextId)
|
|
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"
|
|
style={{ width: 220 }}
|
|
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"
|
|
style={{ width: 200 }}
|
|
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" 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.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'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>
|
|
)
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-8 mb-18">
|
|
<input
|
|
className="input"
|
|
style={{ maxWidth: 320 }}
|
|
placeholder="Filter by name, headline, company…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
<span className="text-muted text-sm">
|
|
{visible.length} of {profiles.length} profile{profiles.length === 1 ? '' : 's'}
|
|
</span>
|
|
</div>
|
|
<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}
|
|
/>
|
|
))}
|
|
</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>
|
|
) : (
|
|
<button
|
|
className="btn"
|
|
disabled={runInFlight || starting.isPending}
|
|
onClick={() => setConfirmOpen(true)}
|
|
>
|
|
<Icon name={runInFlight ? 'clock' : 'search'} />
|
|
{runInFlight ? 'Sourcing…' : 'Search LinkedIn for more'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{viewProfileId && (
|
|
<TalentProfileDetail profileId={viewProfileId} onClose={() => setViewProfileId(null)} />
|
|
)}
|
|
|
|
{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's title, technical requirements and experience level, {locationLabel} —
|
|
up to 25 profiles per run (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>
|
|
)
|
|
}
|