Skip to content

Commit ffedb75

Browse files
committed
feat(components): JobMatchScore con score 0-100, radar chart SVG 4 assi e breakdown categorie
2 parents 1e32a00 + 0429915 commit ffedb75

1 file changed

Lines changed: 159 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
'use client'
2+
3+
// ── Types ──────────────────────────────────────────────────────────────────
4+
5+
export type MatchCategory = {
6+
id: string
7+
label: string
8+
score: number // 0-100
9+
icon?: string
10+
}
11+
12+
export const DEFAULT_MATCH_CATEGORIES: MatchCategory[] = [
13+
{ id: 'skills', label: 'Competenze', score: 0, icon: '⚡' },
14+
{ id: 'experience', label: 'Esperienza', score: 0, icon: '📋' },
15+
{ id: 'education', label: 'Formazione', score: 0, icon: '🎓' },
16+
{ id: 'location', label: 'Posizione', score: 0, icon: '📍' },
17+
]
18+
19+
// ── Helpers ────────────────────────────────────────────────────────────────
20+
21+
function scoreColor(s: number) {
22+
if (s >= 80) return 'var(--color-green)'
23+
if (s >= 60) return 'var(--color-yellow)'
24+
if (s >= 40) return 'var(--color-blue)'
25+
return 'var(--color-red)'
26+
}
27+
28+
function radarPoints(cats: MatchCategory[], cx: number, cy: number, r: number): string {
29+
return cats.map((c, i) => {
30+
const angle = (i / cats.length) * 2 * Math.PI - Math.PI / 2
31+
const v = (c.score / 100) * r
32+
return `${cx + v * Math.cos(angle)},${cy + v * Math.sin(angle)}`
33+
}).join(' ')
34+
}
35+
36+
function webPoints(cx: number, cy: number, r: number, n: number, frac: number): string {
37+
return Array.from({ length: n }, (_, i) => {
38+
const angle = (i / n) * 2 * Math.PI - Math.PI / 2
39+
return `${cx + r * frac * Math.cos(angle)},${cy + r * frac * Math.sin(angle)}`
40+
}).join(' ')
41+
}
42+
43+
// ── Radar Chart ────────────────────────────────────────────────────────────
44+
45+
function RadarChart({ categories, size = 140 }: { categories: MatchCategory[]; size?: number }) {
46+
const cx = size / 2
47+
const cy = size / 2
48+
const r = size / 2 - 18
49+
const n = categories.length
50+
const avg = Math.round(categories.reduce((s, c) => s + c.score, 0) / n)
51+
const fill = scoreColor(avg)
52+
53+
return (
54+
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
55+
{/* Web rings */}
56+
{[0.25, 0.5, 0.75, 1].map(frac => (
57+
<polygon key={frac} points={webPoints(cx, cy, r, n, frac)}
58+
fill="none" stroke="var(--color-border)" strokeWidth={frac === 1 ? 1 : 0.5} />
59+
))}
60+
61+
{/* Axes */}
62+
{categories.map((_, i) => {
63+
const angle = (i / n) * 2 * Math.PI - Math.PI / 2
64+
return (
65+
<line key={i}
66+
x1={cx} y1={cy}
67+
x2={cx + r * Math.cos(angle)}
68+
y2={cy + r * Math.sin(angle)}
69+
stroke="var(--color-border)" strokeWidth={0.5} />
70+
)
71+
})}
72+
73+
{/* Score polygon */}
74+
<polygon points={radarPoints(categories, cx, cy, r)}
75+
fill={`${fill}22`} stroke={fill} strokeWidth={1.5} strokeLinejoin="round" />
76+
77+
{/* Axis labels */}
78+
{categories.map((c, i) => {
79+
const angle = (i / n) * 2 * Math.PI - Math.PI / 2
80+
const lx = cx + (r + 12) * Math.cos(angle)
81+
const ly = cy + (r + 12) * Math.sin(angle)
82+
return (
83+
<text key={c.id} x={lx} y={ly}
84+
textAnchor="middle" dominantBaseline="middle"
85+
fontSize={8} fill="var(--color-dim)">
86+
{c.icon ?? c.label[0]}
87+
</text>
88+
)
89+
})}
90+
</svg>
91+
)
92+
}
93+
94+
// ── CategoryRow ────────────────────────────────────────────────────────────
95+
96+
function CategoryRow({ cat }: { cat: MatchCategory }) {
97+
const color = scoreColor(cat.score)
98+
return (
99+
<div className="flex items-center gap-2">
100+
<span className="text-[11px] w-4 text-center flex-shrink-0">{cat.icon}</span>
101+
<span className="text-[10px] w-20 truncate flex-shrink-0" style={{ color: 'var(--color-muted)' }}>{cat.label}</span>
102+
<div className="flex-1 h-1 rounded-full overflow-hidden" style={{ background: 'var(--color-border)' }}>
103+
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${cat.score}%`, background: color }} />
104+
</div>
105+
<span className="text-[10px] font-mono w-7 text-right flex-shrink-0" style={{ color }}>{cat.score}</span>
106+
</div>
107+
)
108+
}
109+
110+
// ── JobMatchScore ──────────────────────────────────────────────────────────
111+
112+
type JobMatchScoreProps = {
113+
score: number
114+
categories: MatchCategory[]
115+
jobTitle?: string
116+
className?: string
117+
}
118+
119+
export function JobMatchScore({ score, categories, jobTitle, className }: JobMatchScoreProps) {
120+
const color = scoreColor(score)
121+
const label = score >= 80 ? 'Ottimo' : score >= 60 ? 'Buono' : score >= 40 ? 'Discreto' : 'Basso'
122+
123+
return (
124+
<div className={`rounded-xl overflow-hidden ${className ?? ''}`}
125+
style={{ border: '1px solid var(--color-border)', background: 'var(--color-panel)' }}>
126+
127+
{/* Header */}
128+
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--color-border)' }}>
129+
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: 'var(--color-dim)' }}>
130+
Match score{jobTitle ? ` — ${jobTitle}` : ''}
131+
</p>
132+
</div>
133+
134+
<div className="p-4 flex gap-4 items-center">
135+
{/* Radar */}
136+
<div className="flex-shrink-0">
137+
<RadarChart categories={categories} size={140} />
138+
</div>
139+
140+
{/* Right panel */}
141+
<div className="flex-1 min-w-0 flex flex-col gap-3">
142+
{/* Big score */}
143+
<div className="flex items-baseline gap-2">
144+
<span className="text-[36px] font-bold font-mono leading-none" style={{ color }}>{score}</span>
145+
<div className="flex flex-col">
146+
<span className="text-[10px] font-mono" style={{ color: 'var(--color-dim)' }}>/100</span>
147+
<span className="text-[10px] font-semibold" style={{ color }}>{label}</span>
148+
</div>
149+
</div>
150+
151+
{/* Category bars */}
152+
<div className="flex flex-col gap-1.5">
153+
{categories.map(c => <CategoryRow key={c.id} cat={c} />)}
154+
</div>
155+
</div>
156+
</div>
157+
</div>
158+
)
159+
}

0 commit comments

Comments
 (0)