pull/17/head
ahmed.mujtaba 2026-08-18 12:47:25 +05:00
commit 27b5bc94c4
8 changed files with 444 additions and 56 deletions

View File

@ -1,32 +1,33 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<!-- viewport-fit=cover lets the layout reach under the iOS notch/home bar;
the safe-area insets in styles.css keep content clear of them.
No maximum-scale/user-scalable — pinch-zoom must stay available. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" />
<!-- Per-route titles are set at runtime by useRouteMeta. -->
<title>TalentFlow · Applicant Tracking System</title>
<meta name="theme-color" content="#004d43" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0e1d1f" media="(prefers-color-scheme: dark)" />
<!-- Utopia brand type: Belleza (main headings) + Inter as the metric-
compatible stand-in for Neue Montreal, which is a licensed face.
If Neue Montreal is installed locally it wins via the CSS stack. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<!-- viewport-fit=cover lets the layout reach under the iOS notch/home bar;
the safe-area insets in styles.css keep content clear of them.
No maximum-scale/user-scalable — pinch-zoom must stay available. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" />
<!-- Per-route titles are set at runtime by useRouteMeta. -->
<title>TalentFlow · Applicant Tracking System</title>
<meta name="theme-color" content="#004d43" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0e1d1f" media="(prefers-color-scheme: dark)" />
<!-- Utopia brand type: Belleza (main headings) + Inter as the metric-
compatible stand-in for Neue Montreal, which is a licensed face.
If Neue Montreal is installed locally it wins via the CSS stack. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<script type="module" crossorigin src="/assets/index-CHbnEeyr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bs8zcrqC.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -1,8 +1,8 @@
{
"hash": "e445d3fc",
"configHash": "4ee64dba",
"hash": "789e2a06",
"configHash": "c5b65d5f",
"lockfileHash": "fac4afd8",
"browserHash": "340fe321",
"browserHash": "8203abd8",
"optimized": {
"react": {
"src": "../../react/index.js",

View File

@ -25,7 +25,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { seedQuery } from '../data/seedQueries'
@ -140,6 +140,15 @@ export default function CandidateProfile({
success: (next) => (next ? `${c.name} added to favorites` : 'Removed from favorites'),
})
// Same PATCH as favorite: the server writes rating onto every inbox row the
// candidate owns and returns the refreshed detail payload.
const rating = Number(live?.rating ?? 0)
const setRating = useProfileWrite({
userId: c.userId,
mutationFn: (next) => candidatesApi.update(c.userId, { rating: next }),
success: (next) => `Rating saved — ${next}/5`,
})
// Score the candidate's inbox CV against the assigned job with the ATS engine.
// Needs both an assigned job (what to score against) and a message (whose
// attachment to score); the refetch lands the new ai_score in this modal.
@ -254,7 +263,17 @@ export default function CandidateProfile({
<Info label="Recruiter" val={live.recruiter} />
<Info label="Applied On" val={fmtWhen(live.applied)} />
<Info label="Screened On" val={fmtWhen(live.matched_at)} />
<Info label="Rating" val={`${(live.rating ?? 0).toFixed(1)} / 5.0`} />
<div className="info-item">
<div className="il">Rating</div>
<div className="iv flex items-center gap-8">
<Stars
value={Math.round(rating)}
disabled={setRating.isPending}
onChange={(n) => setRating.mutate(n)}
/>
<span className="cell-sub">{rating.toFixed(1)} / 5.0</span>
</div>
</div>
<Info label="Applications" val={live.job_posts?.length || 0} />
</div>

View File

@ -14,6 +14,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import JobCandidates from './JobCandidates'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
@ -262,6 +263,11 @@ export default function CvImport() {
</div>
</div>
</div>
{/* Everything ever scored against the selected job this batch, earlier
uploads and synced inbox CVs alike. The scoring mutation invalidates
qk.candidates.all(), so the grid refreshes as each batch lands. */}
<JobCandidates jobId={jobId} jobTitle={selectedJob?.title} />
</div>
)
}

View File

@ -346,27 +346,6 @@ export default function Interviews() {
)
}
/** Star rating — replaces the imperative Interviews._bindStars() DOM toggling. */
function Stars({ value, onChange }) {
return (
<div className="rating-stars">
{[1, 2, 3, 4, 5].map((n) => (
<span
key={n}
className={`rs${n <= value ? ' on' : ''}`}
onClick={() => onChange(n)}
role="radio"
aria-checked={n === value}
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onChange(n) } }}
>
<Icon name="star" />
</span>
))}
</div>
)
}
function CriteriaList({ criteria, ratings, setRating }) {
return criteria.map((c) => (
<div className="setting-row" style={{ padding: '12px 0' }} key={c}>

View File

@ -0,0 +1,341 @@
/* ============================================================
JobCandidates the per-job scored-candidates card grid + detail modal
(the engine test UI's card layout, rebuilt on the portal's primitives).
Fed by GET /candidate/scored/fetch?job_id= via toCandidateView. Upload and
inbox rows live in the same table, so CVs scored from CV Import and CVs
synced from the mailbox both render here; `source` tells them apart.
Completed rows arrive score-desc from the server; failed rows follow in
upload order and render as failed cards rather than disappearing.
============================================================ */
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ProgressBar } from '../ui/primitives'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Email' }
/** Extraction keeps the resume's own casing; SHOUTED headers read badly on cards. */
function displayName(name) {
if (!name || /[a-z]/.test(name)) return name
return name.toLowerCase().replace(/\p{L}+/gu, (w) => w[0].toUpperCase() + w.slice(1))
}
function bandOf(score) {
if (score == null) return null
return score >= 82 ? 'Strong Match' : score >= 65 ? 'Potential Match' : 'Weak Match'
}
function ringColor(score) {
return score >= 82 ? 'var(--success)' : score >= 65 ? 'var(--warning)' : 'var(--danger)'
}
/** The 120px .ats-ring shrunk to card size — same conic trick, no new CSS. */
function MiniRing({ score, size = 46 }) {
return (
<div
style={{
width: size, height: size, borderRadius: '50%', flexShrink: 0,
display: 'grid', placeItems: 'center',
background: `conic-gradient(${ringColor(score)} ${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: size >= 56 ? 17 : 13.5, letterSpacing: '-.3px',
color: ringColor(score),
}}
>
{score}
</div>
</div>
)
}
function CandidateCard({ c, onView }) {
const matched = c.matchedSkills.slice(0, 4)
const missing = c.missingSkills.slice(0, 2)
const more = (c.matchedSkills.length - matched.length) + (c.missingSkills.length - missing.length)
return (
<div className="card cand-card" onClick={() => onView(c)}>
<div className="card-body">
<div className="cand-head">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div className="cand-id">
<div className="cand-name">{displayName(c.name)}</div>
<div className="cand-role">{c.currentTitle ?? '—'}</div>
</div>
<MiniRing score={c.aiScore} />
</div>
<div className="cand-skills">
{matched.map((s) => <span className="cand-chip" key={s}>{s}</span>)}
{missing.map((s) => (
<span className="cand-chip miss" key={s}><Icon name="x" /> {s}</span>
))}
{more > 0 && <span className="cand-chip more">+{more} more</span>}
</div>
<p className="cand-crit">{c.critique}</p>
<div className="cand-foot">
<span className="cand-meta"><Icon name="briefcase" /> {c.experience != null ? `${c.experience} yrs` : '—'}</span>
<span className="cand-company">{c.currentCompany ?? ''}</span>
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
<button className="act-btn" data-tip="View" onClick={(e) => { e.stopPropagation(); onView(c) }}>
<Icon name="eye" />
</button>
</div>
</div>
</div>
)
}
function FailedCard({ c, onView }) {
return (
<div className="card cand-card" onClick={() => onView(c)}>
<div className="card-body">
<div className="cand-head">
<span className="kpi-icn i-red" style={{ width: 44, height: 44, borderRadius: 12, flexShrink: 0 }}><Icon name="file" /></span>
<div className="cand-id">
<div className="cand-name">{c.filename}</div>
<div className="cand-role">Could not be scored</div>
</div>
</div>
<p className="cand-crit" style={{ height: 'auto' }}>
{c.errorMessage ?? 'No usable text could be extracted from this file.'}
</p>
<div className="cand-foot">
<Badge className="b-red">{c.errorCode ?? 'FAILED'}</Badge>
<span className="cand-company" />
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
<button className="act-btn" data-tip="View" onClick={(e) => { e.stopPropagation(); onView(c) }}>
<Icon name="eye" />
</button>
</div>
</div>
</div>
)
}
/** Screenshot-style detail: hero header + Overview / Job Match tabs. */
export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
const completed = c.scoringStatus === 'completed'
const TABS = completed ? ['Overview', 'Job Match'] : ['Overview']
const [tab, setTab] = useState('Overview')
const band = bandOf(c.aiScore)
const bandCls = band === 'Strong Match' ? 'b-green' : band === 'Potential Match' ? 'b-amber' : 'b-red'
const roleLine = [c.currentTitle, c.currentCompany].filter(Boolean).join(' at ') || c.filename
return (
<Modal title="Candidate Profile" subtitle={c.filename} size="modal-lg" onClose={onClose}
footer={<button className="btn btn-primary" onClick={onClose}>Close</button>}
>
<div className="profile-hero">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">{displayName(c.name)}</div>
<div className="ph-role">{roleLine}</div>
<div className="ph-tags">
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source ?? '—'}</Badge>
{c.applied && <Badge className="b-plain b-indigo badge-plain">Received {fmtDate(c.applied)}</Badge>}
{c.experience != null && (
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
)}
</div>
</div>
{completed && (
<div style={{ textAlign: 'center' }}>
<MiniRing score={c.aiScore} size={64} />
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
</div>
)}
</div>
<div style={{ marginTop: 22 }}>
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} />
</div>
<div className="tab-pane active">
{tab === 'Overview' && (
<>
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Candidate</div><div className="iv">{displayName(c.name)}</div></div>
<div className="info-item"><div className="il">Current Title</div><div className="iv">{c.currentTitle ?? '—'}</div></div>
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany ?? '—'}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience != null ? `${c.experience} years` : '—'}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{SOURCE_LABEL[c.source] ?? c.source ?? '—'}</div></div>
<div className="info-item"><div className="il">File</div><div className="iv">{c.filename ?? '—'}</div></div>
<div className="info-item"><div className="il">Scored For</div><div className="iv">{jobTitle ?? '—'}</div></div>
<div className="info-item"><div className="il">Added On</div><div className="iv">{c.applied ? fmtDate(c.applied) : '—'}</div></div>
</div>
{completed ? (
<>
<div className="form-section-title" style={{ marginTop: 0 }}>AI Assessment</div>
<p className="text-muted">{c.critique ?? '—'}</p>
</>
) : (
<EmptyState icon="alert" title={c.errorCode ?? 'FAILED'}>
{c.errorMessage ?? 'This CV could not be scored.'}
</EmptyState>
)}
</>
)}
{tab === 'Job Match' && completed && (
<>
<div className="form-section-title" style={{ marginTop: 0 }}>Job-Match Score</div>
<p className="text-muted text-sm" style={{ marginBottom: 14 }}>
Match this candidate against the job the CV was scored for.
</p>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
<div className="card-body flex items-center gap-12">
<MiniRing score={c.aiScore} size={56} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="fw-600" style={{ marginBottom: 8 }}>{jobTitle ?? 'Selected job'}</div>
<ProgressBar pct={c.aiScore} />
</div>
{band && <Badge className={bandCls}>{band}</Badge>}
</div>
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>
Matched must-have skills ({c.matchedSkills.length})
</div>
<div className="k-tags" style={{ marginBottom: 16 }}>
{c.matchedSkills.length
? c.matchedSkills.map((s) => (
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
))
: <span className="text-muted"></span>}
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>
Missing must-have skills ({c.missingSkills.length})
</div>
<div className="k-tags" style={{ marginBottom: 16 }}>
{c.missingSkills.length
? c.missingSkills.map((s) => (
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
))
: <span className="text-muted">None full match</span>}
</div>
<div className="divider" />
<p className="text-muted text-sm">
<Icon name="sparkles" /> Matched skills are verified to appear in the resume text;
missing skills use the job description's wording.
</p>
</>
)}
</div>
</Modal>
)
}
export default function JobCandidates({ jobId, jobTitle }) {
const [q, setQ] = useState('')
const [filter, setFilter] = useState('all')
const [viewing, setViewing] = useState(null)
const query = useQuery({
queryKey: qk.candidates.list({ jobId }),
queryFn: async () => {
const res = await candidatesApi.listCandidates({ jobId })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(candidatesApi.toCandidateView)
},
enabled: Boolean(jobId),
})
const rows = useMemo(() => query.data ?? [], [query.data])
const scored = rows.filter((r) => r.scoringStatus === 'completed').length
const failed = rows.length - scored
const list = useMemo(
() =>
rows.filter((c) => {
if (filter === 'completed' && c.scoringStatus !== 'completed') return false
if (filter === 'failed' && c.scoringStatus !== 'failed') return false
if (q) {
const hay = [
c.name, c.filename ?? '', c.currentTitle ?? '',
c.currentCompany ?? '', c.matchedSkills.join(' '),
].join(' ').toLowerCase()
if (!hay.includes(q.toLowerCase())) return false
}
return true
}),
[rows, q, filter],
)
if (!jobId) return null
return (
<div style={{ marginTop: 24 }}>
<div style={{ marginBottom: 14 }}>
<h2 className="page-title" style={{ fontSize: 20 }}>Candidates</h2>
<p className="page-sub">
{rows.length} candidate{rows.length === 1 ? '' : 's'} · {scored} scored · {failed} failed
{jobTitle ? ` · vs ${jobTitle}` : ''}
</p>
</div>
<div className="card mb-18">
<div className="card-body" style={{ padding: 16 }}>
<div className="toolbar" style={{ marginBottom: 0 }}>
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, skill, company…" />
</div>
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">All results</option>
<option value="completed">Scored</option>
<option value="failed">Failed</option>
</select>
</div>
</div>
</div>
<div className="grid g-3">
{list.length === 0 ? (
<div style={{ gridColumn: '1/-1' }}>
{query.isError ? (
<EmptyState title="Could not load candidates">
{friendlyAuthError(query.error, 'Please try again.')}
</EmptyState>
) : query.isPending ? (
<EmptyState title="Loading candidates…">Fetching scored CVs for this job.</EmptyState>
) : rows.length === 0 ? (
<EmptyState title="No candidates yet">
Upload CVs above or score synced inbox CVs against this job.
</EmptyState>
) : (
<EmptyState title="No matches">Try a different search or filter.</EmptyState>
)}
</div>
) : (
list.map((c) =>
c.scoringStatus === 'completed'
? <CandidateCard key={c.id} c={c} onView={setViewing} />
: <FailedCard key={c.id} c={c} onView={setViewing} />,
)
)}
</div>
{viewing && (
<ScoredCandidateDetail candidate={viewing} jobTitle={jobTitle} onClose={() => setViewing(null)} />
)}
</div>
)
}

View File

@ -896,6 +896,26 @@ canvas { width: 100%; max-width: 100%; display: block; }
.skill-matched { background: var(--success-soft); color: var(--success); }
.skill-missing { background: var(--danger-soft); color: var(--danger); }
/* Per-job scored-candidate cards (JobCandidates.jsx). Every zone has a fixed
height so the grid rows align regardless of how much text a CV produced. */
.cand-card { display: flex; flex-direction: column; cursor: pointer; transition: .15s; }
.cand-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); transform: translateY(-1px); }
.cand-card > .card-body { display: flex; flex-direction: column; flex: 1; padding: 18px; }
.cand-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
.cand-id { flex: 1; min-width: 0; }
.cand-name { font-weight: 700; font-size: 14.5px; letter-spacing: -.1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cand-role { font-size: 12.5px; color: var(--text-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cand-skills { display: flex; flex-wrap: wrap; gap: 6px; align-content: flex-start; height: 54px; overflow: hidden; margin-bottom: 12px; }
.cand-chip { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 10px; border-radius: 7px; font-size: 12px; font-weight: 600; background: var(--bg-sunken); color: var(--text-2); white-space: nowrap; }
.cand-chip svg { width: 11px; height: 11px; }
.cand-chip.miss { background: var(--danger-soft); color: var(--danger); }
.cand-chip.more { background: transparent; color: var(--text-3); padding: 0 4px; }
.cand-crit { font-size: 13px; line-height: 1.55; color: var(--text-2); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; height: 60px; margin-bottom: 14px; }
.cand-foot { margin-top: auto; display: flex; align-items: center; gap: 10px; padding-top: 12px; border-top: 1px solid var(--border); }
.cand-meta { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-3); white-space: nowrap; flex-shrink: 0; }
.cand-meta svg { width: 13px; height: 13px; }
.cand-company { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--text-3); }
/* Platform publish card */
.platform-card { display: flex; align-items: center; gap: 14px; padding: 16px; border: 1px solid var(--border); border-radius: 14px; transition: .15s; cursor: pointer; background: var(--bg-elev); }
.platform-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); }

View File

@ -121,4 +121,26 @@ export function FieldError({ children }) {
return <span className={`field-error${children ? ' show' : ''}`}>{children || ''}</span>
}
/** 15 star input (interview scorecards, profile rating). */
export function Stars({ value, onChange, disabled }) {
const set = (n) => { if (!disabled) onChange(n) }
return (
<div className="rating-stars">
{[1, 2, 3, 4, 5].map((n) => (
<span
key={n}
className={`rs${n <= value ? ' on' : ''}`}
onClick={() => set(n)}
role="radio"
aria-checked={n === value}
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(n) } }}
>
<Icon name="star" />
</span>
))}
</div>
)
}
export { Icon }