Redesign UI: modern SaaS dashboard styling with indigo/violet accents
This commit is contained in:
@@ -5,65 +5,308 @@ import JobActions from "./job-actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function getCompanyColor(name: string) {
|
||||
const colors = [
|
||||
"bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950/80 dark:text-indigo-300 dark:border-indigo-800",
|
||||
"bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950/80 dark:text-violet-300 dark:border-violet-800",
|
||||
"bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950/80 dark:text-blue-300 dark:border-blue-800",
|
||||
"bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/80 dark:text-emerald-300 dark:border-emerald-800",
|
||||
"bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-950/80 dark:text-amber-300 dark:border-amber-800",
|
||||
"bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950/80 dark:text-rose-300 dark:border-rose-800",
|
||||
"bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950/80 dark:text-teal-300 dark:border-teal-800",
|
||||
"bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-950/80 dark:text-cyan-300 dark:border-cyan-800",
|
||||
];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < (name || "").length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
function getInitials(name: string) {
|
||||
if (!name) return "?";
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function formatCompensation(j: { payMin: number | null; payMax: number | null; payCurrency: string | null; payPeriod: string | null }) {
|
||||
if (!j.payMin && !j.payMax) return null;
|
||||
const cur = j.payCurrency === "PHP" ? "₱" : "$";
|
||||
const per = j.payPeriod === "hour" ? "/hr" : j.payPeriod === "month" ? "/mo" : j.payPeriod === "year" ? "/yr" : "";
|
||||
const fmt = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`);
|
||||
return `${cur}${fmt(j.payMin ?? 0)}${j.payMax ? ` – ${fmt(j.payMax)}` : ""}${per}`;
|
||||
}
|
||||
|
||||
export default async function JobDetail({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const job = await prisma.job.findUnique({
|
||||
where: { id },
|
||||
include: { resumes: true, coverLetters: true, applications: true },
|
||||
include: {
|
||||
resumes: { orderBy: { updatedAt: "desc" } },
|
||||
coverLetters: { orderBy: { updatedAt: "desc" } },
|
||||
applications: { orderBy: { updatedAt: "desc" } },
|
||||
},
|
||||
});
|
||||
if (!job) notFound();
|
||||
|
||||
const comp = formatCompensation(job);
|
||||
const companyColor = getCompanyColor(job.company);
|
||||
const initials = getInitials(job.company);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Breadcrumb & Top Bar */}
|
||||
<div>
|
||||
<Link href="/jobs" className="text-sm text-zinc-500 hover:text-blue-600">← Jobs</Link>
|
||||
<h1 className="text-2xl font-bold mt-1">{job.title}</h1>
|
||||
<div className="text-zinc-500">{job.company} · {job.location ?? "Remote"} · PH: {job.phEligibility}</div>
|
||||
<div className="mt-2 flex gap-3 text-sm">
|
||||
{job.url && <a href={job.url} target="_blank" className="text-blue-600 hover:underline">Listing ↗</a>}
|
||||
{job.applyUrl && <a href={job.applyUrl} target="_blank" className="text-blue-600 hover:underline">Apply page ↗</a>}
|
||||
<Link
|
||||
href="/jobs"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors mb-3"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
<span>Back to Jobs</span>
|
||||
</Link>
|
||||
|
||||
{/* Main Header Card */}
|
||||
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<div className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl font-bold text-base border shadow-xs ${companyColor}`}>
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white sm:text-3xl">
|
||||
{job.title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2.5 mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<span className="font-semibold text-zinc-800 dark:text-zinc-200">{job.company}</span>
|
||||
<span>·</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
{job.location ?? "Remote"}
|
||||
</span>
|
||||
{job.remoteType && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="capitalize">{job.remoteType}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Badges & Meta */}
|
||||
<div className="flex flex-wrap items-center gap-2 mt-3">
|
||||
{job.phEligibility === "yes" ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-300 dark:border-emerald-800/60">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
PH Eligible
|
||||
</span>
|
||||
) : job.phEligibility === "no" ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-rose-50 text-rose-700 border border-rose-200 dark:bg-rose-950/50 dark:text-rose-300 dark:border-rose-800/60">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" />
|
||||
Not Eligible
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-zinc-100 text-zinc-600 border border-zinc-200 dark:bg-zinc-800/60 dark:text-zinc-400 dark:border-zinc-700/60">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-400" />
|
||||
PH: Unclear
|
||||
</span>
|
||||
)}
|
||||
|
||||
{comp && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-50 text-indigo-700 border border-indigo-200/80 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-800/60">
|
||||
💰 {comp}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{job.tags && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
🏷️ {job.tags}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* External Links */}
|
||||
<div className="flex sm:flex-col gap-2 shrink-0 pt-2 sm:pt-0">
|
||||
{job.url && (
|
||||
<a
|
||||
href={job.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium text-zinc-700 dark:text-zinc-300 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<span>Listing</span>
|
||||
<svg className="w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{job.applyUrl && (
|
||||
<a
|
||||
href={job.applyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-950/60 border border-indigo-200 dark:border-indigo-900/60 hover:bg-indigo-100 dark:hover:bg-indigo-900/80 transition-colors"
|
||||
>
|
||||
<span>Apply Page</span>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Toolbar */}
|
||||
<JobActions job={JSON.parse(JSON.stringify(job))} />
|
||||
|
||||
{job.description && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Description</h2>
|
||||
<p className="text-sm whitespace-pre-wrap">{job.description}</p>
|
||||
</section>
|
||||
)}
|
||||
{job.requirements && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Requirements</h2>
|
||||
<p className="text-sm whitespace-pre-wrap">{job.requirements}</p>
|
||||
</section>
|
||||
)}
|
||||
{job.notes && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Notes</h2>
|
||||
<p className="text-sm whitespace-pre-wrap">{job.notes}</p>
|
||||
{/* Tailored Resumes Section */}
|
||||
{job.resumes.length > 0 && (
|
||||
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
<span>Tailored Resumes ({job.resumes.length})</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{job.resumes.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="flex items-center justify-between p-3.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-800/40 hover:border-purple-300 dark:hover:border-purple-800 transition-all group"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/resumes/${r.id}`}
|
||||
className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors truncate block"
|
||||
>
|
||||
{r.name}
|
||||
</Link>
|
||||
<p className="text-xs text-zinc-400 mt-0.5 capitalize">
|
||||
{r.kind} · v{r.version}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/resumes/${r.id}`}
|
||||
className="px-3 py-1 rounded-lg text-xs font-medium text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/60 border border-purple-200/80 dark:border-purple-900/60 hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
Edit / Print →
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{job.resumes.length > 0 && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Tailored resumes</h2>
|
||||
<ul className="text-sm space-y-1">
|
||||
{job.resumes.map((r) => (
|
||||
<li key={r.id}><Link href={`/resumes/${r.id}`} className="text-blue-600 hover:underline">{r.name}</Link></li>
|
||||
{/* Cover Letters Section */}
|
||||
{job.coverLetters.length > 0 && (
|
||||
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-3">
|
||||
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-indigo-600 dark:text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
<span>Cover Letters ({job.coverLetters.length})</span>
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{job.coverLetters.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="rounded-xl border border-zinc-200 dark:border-zinc-800 p-4 bg-zinc-50/50 dark:bg-zinc-950/40 text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed font-sans"
|
||||
>
|
||||
{c.markdown}
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{job.coverLetters.length > 0 && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Cover letters</h2>
|
||||
<ul className="text-sm space-y-2">
|
||||
{job.coverLetters.map((c) => (
|
||||
<li key={c.id} className="rounded border border-zinc-200 dark:border-zinc-800 p-3 whitespace-pre-wrap bg-white dark:bg-zinc-900">{c.markdown}</li>
|
||||
|
||||
{/* Description */}
|
||||
{job.description && (
|
||||
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-3">
|
||||
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" />
|
||||
</svg>
|
||||
<span>Job Description</span>
|
||||
</h2>
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed">
|
||||
{job.description}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Requirements */}
|
||||
{job.requirements && (
|
||||
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-3">
|
||||
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Requirements</span>
|
||||
</h2>
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed">
|
||||
{job.requirements}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{job.notes && (
|
||||
<section className="rounded-2xl border border-amber-200/80 dark:border-amber-900/60 bg-amber-50/30 dark:bg-amber-950/20 p-6 shadow-xs space-y-2">
|
||||
<h2 className="text-sm font-bold text-amber-800 dark:text-amber-300 flex items-center gap-2">
|
||||
<span>📝 Private Notes</span>
|
||||
</h2>
|
||||
<p className="text-sm text-amber-900/80 dark:text-amber-200/80 whitespace-pre-wrap leading-relaxed">
|
||||
{job.notes}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Applications Log */}
|
||||
{job.applications.length > 0 && (
|
||||
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-3">
|
||||
<h2 className="text-base font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
||||
</svg>
|
||||
<span>Application Submissions</span>
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{job.applications.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl bg-zinc-50/70 dark:bg-zinc-800/50 text-xs text-zinc-600 dark:text-zinc-400"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="capitalize font-medium text-zinc-800 dark:text-zinc-200">
|
||||
Method: {a.method}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{a.submittedAt
|
||||
? `Submitted on ${new Date(a.submittedAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}`
|
||||
: "Pending submission"}
|
||||
</span>
|
||||
</div>
|
||||
{a.confirmation && (
|
||||
<span className="font-mono text-[11px] text-zinc-500">Ref: {a.confirmation}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user