Skip to content

Commit 68ca14e

Browse files
committed
merge: pagina /compare — comparatore offerte side-by-side con tabella salary, benefits, skills, rating
2 parents e533977 + adadfab commit 68ca14e

2 files changed

Lines changed: 164 additions & 0 deletions

File tree

web/app/api/compare/route.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* API Compare — comparatore offerte lavoro side-by-side
3+
*/
4+
import { NextResponse } from 'next/server';
5+
import fs from 'node:fs';
6+
import path from 'node:path';
7+
import os from 'node:os';
8+
9+
export const dynamic = 'force-dynamic';
10+
11+
type Job = { id: string; title: string; company: string; location: string; salary: { min: number; max: number; currency: string }; benefits: string[]; skills: string[]; rating: number; remote: boolean; type: string };
12+
13+
const FILE = path.join(os.homedir(), '.jht', 'jobs.json');
14+
15+
function loadJobs(): Job[] {
16+
try { const d = JSON.parse(fs.readFileSync(FILE, 'utf-8')); return Array.isArray(d) ? d : []; }
17+
catch { return []; }
18+
}
19+
20+
function sampleJobs(): Job[] {
21+
return [
22+
{ id: 'j1', title: 'Senior Full Stack Developer', company: 'TechCorp', location: 'Milano', salary: { min: 45000, max: 55000, currency: 'EUR' }, benefits: ['Smart working', 'Buoni pasto', 'Formazione', 'Assicurazione'], skills: ['React', 'Node.js', 'TypeScript', 'PostgreSQL'], rating: 4.2, remote: true, type: 'Full-time' },
23+
{ id: 'j2', title: 'Backend Engineer', company: 'StartupXYZ', location: 'Roma', salary: { min: 38000, max: 48000, currency: 'EUR' }, benefits: ['Stock options', 'Remoto 100%', 'Hardware budget'], skills: ['Python', 'Django', 'AWS', 'Docker'], rating: 3.8, remote: true, type: 'Full-time' },
24+
{ id: 'j3', title: 'Frontend Developer', company: 'DesignStudio', location: 'Torino', salary: { min: 35000, max: 42000, currency: 'EUR' }, benefits: ['Orario flessibile', 'Buoni pasto', 'Palestra'], skills: ['React', 'CSS', 'Figma', 'Next.js'], rating: 4.5, remote: false, type: 'Full-time' },
25+
{ id: 'j4', title: 'DevOps Engineer', company: 'CloudInc', location: 'Bologna', salary: { min: 42000, max: 52000, currency: 'EUR' }, benefits: ['Full remote', 'Formazione AWS', 'Bonus annuale'], skills: ['Kubernetes', 'Terraform', 'CI/CD', 'Linux'], rating: 4.0, remote: true, type: 'Full-time' },
26+
{ id: 'j5', title: 'Data Engineer', company: 'BigFinance', location: 'Milano', salary: { min: 50000, max: 65000, currency: 'EUR' }, benefits: ['Bonus performance', 'Mensa', 'Assicurazione', 'Auto aziendale'], skills: ['Spark', 'Python', 'SQL', 'Airflow'], rating: 3.5, remote: false, type: 'Full-time' },
27+
];
28+
}
29+
30+
export async function GET() {
31+
let jobs = loadJobs();
32+
if (!jobs.length) jobs = sampleJobs();
33+
return NextResponse.json({ jobs: jobs.map(j => ({ id: j.id, title: j.title, company: j.company })) });
34+
}
35+
36+
export async function POST(req: Request) {
37+
try {
38+
const { ids } = await req.json() as { ids: string[] };
39+
if (!ids?.length || ids.length < 2 || ids.length > 3) return NextResponse.json({ error: 'Seleziona 2-3 offerte da comparare' }, { status: 400 });
40+
let jobs = loadJobs();
41+
if (!jobs.length) jobs = sampleJobs();
42+
const selected = ids.map(id => jobs.find(j => j.id === id)).filter(Boolean) as Job[];
43+
if (selected.length < 2) return NextResponse.json({ error: 'Offerte non trovate' }, { status: 404 });
44+
const maxSalary = Math.max(...selected.map(j => j.salary.max));
45+
const comparison = selected.map(j => ({
46+
...j,
47+
salaryScore: Math.round(j.salary.max / maxSalary * 100),
48+
benefitCount: j.benefits.length,
49+
skillCount: j.skills.length,
50+
}));
51+
return NextResponse.json({ comparison });
52+
} catch (err) { return NextResponse.json({ error: String(err) }, { status: 500 }); }
53+
}

web/app/compare/page.tsx

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
'use client'
2+
3+
import Link from 'next/link'
4+
import { useEffect, useState, useCallback } from 'react'
5+
6+
type JobOption = { id: string; title: string; company: string }
7+
type ComparedJob = { id: string; title: string; company: string; location: string; salary: { min: number; max: number; currency: string }; benefits: string[]; skills: string[]; rating: number; remote: boolean; type: string; salaryScore: number; benefitCount: number; skillCount: number }
8+
9+
function Stars({ rating }: { rating: number }) {
10+
return <span className="text-[10px]">{'★'.repeat(Math.round(rating))}{'☆'.repeat(5 - Math.round(rating))} <span className="text-[var(--color-dim)]">{rating.toFixed(1)}</span></span>
11+
}
12+
13+
function Bar({ value, max, color }: { value: number; max: number; color: string }) {
14+
const pct = max ? Math.round(value / max * 100) : 0
15+
return (
16+
<div className="h-2 rounded-full overflow-hidden" style={{ background: 'var(--color-deep)' }}>
17+
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: color }} />
18+
</div>
19+
)
20+
}
21+
22+
export default function ComparePage() {
23+
const [jobs, setJobs] = useState<JobOption[]>([])
24+
const [selected, setSelected] = useState<string[]>([])
25+
const [comparison, setComparison] = useState<ComparedJob[]>([])
26+
const [loading, setLoading] = useState(false)
27+
28+
const fetchJobs = useCallback(async () => {
29+
const res = await fetch('/api/compare').catch(() => null)
30+
if (!res?.ok) return
31+
const d = await res.json()
32+
setJobs(d.jobs ?? [])
33+
}, [])
34+
35+
useEffect(() => { fetchJobs() }, [fetchJobs])
36+
37+
const toggleSelect = (id: string) => {
38+
setSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : prev.length >= 3 ? prev : [...prev, id])
39+
}
40+
41+
const compare = async () => {
42+
if (selected.length < 2) return
43+
setLoading(true)
44+
const res = await fetch('/api/compare', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: selected }) }).catch(() => null)
45+
if (res?.ok) { const d = await res.json(); setComparison(d.comparison ?? []) }
46+
setLoading(false)
47+
}
48+
49+
const maxSalary = Math.max(...comparison.map(j => j.salary.max), 1)
50+
const maxBenefits = Math.max(...comparison.map(j => j.benefitCount), 1)
51+
const maxSkills = Math.max(...comparison.map(j => j.skillCount), 1)
52+
53+
const ROWS: [string, (j: ComparedJob) => React.ReactNode][] = [
54+
['Azienda', j => <span className="text-[11px] text-[var(--color-bright)] font-medium">{j.company}</span>],
55+
['Posizione', j => <span className="text-[10px] text-[var(--color-muted)]">{j.title}</span>],
56+
['Località', j => <span className="text-[10px] text-[var(--color-muted)]">{j.location} {j.remote && <span style={{ color: 'var(--color-green)' }}>· Remote</span>}</span>],
57+
['Tipo', j => <span className="text-[10px] text-[var(--color-dim)]">{j.type}</span>],
58+
['RAL', j => <><span className="text-[11px] text-[var(--color-white)] font-bold">{(j.salary.min / 1000).toFixed(0)}k–{(j.salary.max / 1000).toFixed(0)}k €</span><Bar value={j.salary.max} max={maxSalary} color="var(--color-green)" /></>],
59+
['Rating', j => <Stars rating={j.rating} />],
60+
['Benefits', j => <><Bar value={j.benefitCount} max={maxBenefits} color="#61affe" /><div className="flex flex-wrap gap-1 mt-1">{j.benefits.map(b => <span key={b} className="text-[8px] px-1.5 py-0.5 rounded" style={{ background: 'var(--color-deep)', color: 'var(--color-dim)' }}>{b}</span>)}</div></>],
61+
['Skills', j => <><Bar value={j.skillCount} max={maxSkills} color="#49cc90" /><div className="flex flex-wrap gap-1 mt-1">{j.skills.map(s => <span key={s} className="text-[8px] px-1.5 py-0.5 rounded font-mono" style={{ background: 'var(--color-deep)', color: 'var(--color-muted)' }}>{s}</span>)}</div></>],
62+
]
63+
64+
return (
65+
<div style={{ animation: 'fade-in 0.35s ease both' }}>
66+
<div className="mb-8 pb-6 border-b border-[var(--color-border)]">
67+
<div className="flex items-center gap-2 mb-1">
68+
<Link href="/dashboard" className="text-[10px] text-[var(--color-dim)] hover:text-[var(--color-muted)] no-underline transition-colors">Dashboard</Link>
69+
<span className="text-[var(--color-border)]">/</span>
70+
<span className="text-[10px] text-[var(--color-muted)]">Compara Offerte</span>
71+
</div>
72+
<h1 className="text-2xl font-bold tracking-tight text-[var(--color-white)] mt-3">Compara Offerte</h1>
73+
<p className="text-[var(--color-muted)] text-[11px] mt-1">Seleziona 2-3 offerte per un confronto side-by-side</p>
74+
</div>
75+
76+
{comparison.length === 0 && (
77+
<>
78+
<div className="flex flex-wrap gap-2 mb-4">
79+
{jobs.map(j => (
80+
<button key={j.id} onClick={() => toggleSelect(j.id)} className="px-3 py-2 rounded-lg text-[10px] cursor-pointer transition-colors"
81+
style={{ background: selected.includes(j.id) ? 'var(--color-green)' : 'var(--color-row)', color: selected.includes(j.id) ? '#000' : 'var(--color-muted)', border: '1px solid var(--color-border)' }}>
82+
<span className="font-bold">{j.title}</span> · {j.company}
83+
</button>
84+
))}
85+
</div>
86+
<button onClick={compare} disabled={selected.length < 2 || loading} className="px-4 py-2 rounded-lg text-[11px] font-bold cursor-pointer"
87+
style={{ background: selected.length >= 2 ? 'var(--color-green)' : 'var(--color-border)', color: selected.length >= 2 ? '#000' : 'var(--color-dim)' }}>
88+
{loading ? 'Caricamento...' : `Confronta (${selected.length}/3)`}
89+
</button>
90+
</>
91+
)}
92+
93+
{comparison.length > 0 && (
94+
<>
95+
<button onClick={() => { setComparison([]); setSelected([]) }} className="mb-4 px-3 py-1.5 rounded text-[10px] font-bold cursor-pointer"
96+
style={{ background: 'var(--color-row)', color: 'var(--color-dim)', border: '1px solid var(--color-border)' }}>← Nuovo confronto</button>
97+
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden bg-[var(--color-panel)]">
98+
{ROWS.map(([label, render]) => (
99+
<div key={label} className="flex border-b border-[var(--color-border)]">
100+
<div className="w-24 flex-shrink-0 px-3 py-3 text-[8px] font-bold tracking-widest text-[var(--color-dim)]" style={{ background: 'var(--color-deep)' }}>{label.toUpperCase()}</div>
101+
{comparison.map(j => (
102+
<div key={j.id} className="flex-1 px-4 py-3 border-l border-[var(--color-border)]">{render(j)}</div>
103+
))}
104+
</div>
105+
))}
106+
</div>
107+
</>
108+
)}
109+
</div>
110+
)
111+
}

0 commit comments

Comments
 (0)