Introduce modern, editorial, and minimal visual themes for live preview and ATS-compliant PDF exports

This commit is contained in:
JobsBoard Deployer 2026-09-05 14:25:19 -04:00
parent d9e108ba1c
commit a8dddccbdb
2 changed files with 485 additions and 182 deletions

View file

@ -166,19 +166,19 @@ export async function GET(req: Request) {
doc.on("error", (err) => reject(err));
});
// --- Header ---
// --- Modern ATS Header ---
doc
.fontSize(22)
.fillColor("#111827")
.fontSize(24)
.fillColor("#0f172a")
.font("Helvetica-Bold")
.text(name, { align: "center" });
.text(name, 40, 40, { align: "left" });
if (headline) {
doc
.fontSize(12)
.fontSize(11)
.fillColor("#2563eb")
.font("Helvetica-Bold")
.text(headline, { align: "center" });
.text(headline.toUpperCase(), { align: "left" });
}
const contactParts = [];
@ -188,14 +188,14 @@ export async function GET(req: Request) {
if (contactParts.length > 0) {
doc
.fontSize(9)
.fillColor("#4b5563")
.fontSize(8.5)
.fillColor("#64748b")
.font("Helvetica")
.text(contactParts.join(" | "), { align: "center" });
.text(contactParts.join(" | "), { align: "left" });
}
doc.moveDown(0.8);
doc.strokeColor("#e5e7eb").lineWidth(1).moveTo(40, doc.y).lineTo(572, doc.y).stroke();
doc.moveDown(0.6);
doc.strokeColor("#0f172a").lineWidth(1.5).moveTo(40, doc.y).lineTo(572, doc.y).stroke();
doc.moveDown(0.8);
// --- Professional Summary ---

View file

@ -3,140 +3,339 @@
import React, { useEffect, useState } from "react";
import { Document, Page, Text, View, StyleSheet, PDFDownloadLink } from "@react-pdf/renderer";
const pdfStyles = StyleSheet.create({
page: {
padding: 35,
fontFamily: "Helvetica",
fontSize: 9.5,
color: "#18181b",
lineHeight: 1.45,
},
header: {
marginBottom: 14,
borderBottomWidth: 1.5,
borderBottomColor: "#18181b",
paddingBottom: 8,
},
name: {
fontSize: 18,
fontFamily: "Helvetica-Bold",
color: "#09090b",
marginBottom: 2,
textTransform: "uppercase",
letterSpacing: 0.5,
},
title: {
fontSize: 10.5,
color: "#3f3f46",
marginBottom: 4,
fontFamily: "Helvetica-Bold",
},
contact: {
fontSize: 8.5,
color: "#52525b",
},
section: {
marginBottom: 12,
},
sectionTitle: {
fontSize: 9.5,
fontFamily: "Helvetica-Bold",
color: "#09090b",
borderBottomWidth: 1,
borderBottomColor: "#e4e4e7",
paddingBottom: 2,
marginBottom: 6,
textTransform: "uppercase",
letterSpacing: 0.5,
},
jobHeader: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 2,
},
jobTitle: {
fontFamily: "Helvetica-Bold",
fontSize: 9.5,
},
company: {
color: "#52525b",
fontFamily: "Helvetica",
},
bullet: {
marginLeft: 8,
marginBottom: 2,
fontSize: 8.5,
color: "#27272a",
},
});
// Styles for React-PDF export by theme
const themesPDF = {
modern: StyleSheet.create({
page: {
padding: 38,
fontFamily: "Helvetica",
fontSize: 9.5,
color: "#1e293b",
lineHeight: 1.45,
},
header: {
marginBottom: 14,
borderBottomWidth: 2,
borderBottomColor: "#0f172a",
paddingBottom: 10,
},
name: {
fontSize: 22,
fontFamily: "Helvetica-Bold",
color: "#0f172a",
marginBottom: 3,
letterSpacing: -0.2,
},
title: {
fontSize: 11,
color: "#2563eb",
marginBottom: 6,
fontFamily: "Helvetica-Bold",
textTransform: "uppercase",
letterSpacing: 0.8,
},
contact: {
fontSize: 8.5,
color: "#64748b",
},
section: {
marginBottom: 13,
},
sectionTitle: {
fontSize: 10,
fontFamily: "Helvetica-Bold",
color: "#0f172a",
borderBottomWidth: 1,
borderBottomColor: "#cbd5e1",
paddingBottom: 3,
marginBottom: 7,
textTransform: "uppercase",
letterSpacing: 0.8,
},
jobHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: 2,
},
jobTitle: {
fontFamily: "Helvetica-Bold",
fontSize: 10,
color: "#0f172a",
},
company: {
color: "#2563eb",
fontFamily: "Helvetica-Bold",
fontSize: 9.5,
},
bullet: {
marginLeft: 10,
marginBottom: 2.5,
fontSize: 9,
color: "#334155",
lineHeight: 1.4,
},
skillsRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: 4,
},
skillBadge: {
backgroundColor: "#f1f5f9",
borderWidth: 0.5,
borderColor: "#cbd5e1",
borderRadius: 3,
paddingVertical: 2,
paddingHorizontal: 6,
fontSize: 8,
color: "#334155",
fontFamily: "Helvetica-Bold",
marginBottom: 3,
marginRight: 4,
},
}),
const ResumePDFDocument = ({ data }: { data: any }) => (
<Document>
<Page size="A4" style={pdfStyles.page}>
<View style={pdfStyles.header}>
<Text style={pdfStyles.name}>{data.profile?.name || "Applicant Name"}</Text>
{data.profile?.title && <Text style={pdfStyles.title}>{data.profile.title}</Text>}
<Text style={pdfStyles.contact}>
{[data.profile?.email, data.profile?.phone, data.profile?.location].filter(Boolean).join(" | ")}
</Text>
</View>
editorial: StyleSheet.create({
page: {
padding: 40,
fontFamily: "Times-Roman",
fontSize: 10,
color: "#1c1917",
lineHeight: 1.5,
},
header: {
marginBottom: 16,
borderBottomWidth: 1,
borderBottomColor: "#1c1917",
paddingBottom: 10,
textAlign: "center",
},
name: {
fontSize: 24,
fontFamily: "Times-Bold",
color: "#1c1917",
marginBottom: 4,
letterSpacing: 0.5,
},
title: {
fontSize: 11,
color: "#78350f",
marginBottom: 5,
fontFamily: "Times-Italic",
},
contact: {
fontSize: 9,
color: "#57534e",
},
section: {
marginBottom: 14,
},
sectionTitle: {
fontSize: 10.5,
fontFamily: "Times-Bold",
color: "#1c1917",
borderBottomWidth: 0.5,
borderBottomColor: "#a8a29e",
paddingBottom: 2,
marginBottom: 7,
textTransform: "uppercase",
letterSpacing: 1,
},
jobHeader: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 2,
},
jobTitle: {
fontFamily: "Times-Bold",
fontSize: 10.5,
color: "#1c1917",
},
company: {
color: "#44403c",
fontFamily: "Times-Italic",
fontSize: 10,
},
bullet: {
marginLeft: 10,
marginBottom: 2.5,
fontSize: 9.5,
color: "#292524",
},
skillsRow: {
flexDirection: "row",
flexWrap: "wrap",
},
skillBadge: {
fontSize: 9,
color: "#292524",
fontFamily: "Times-Roman",
marginRight: 6,
},
}),
{data.profile?.summary && (
<View style={pdfStyles.section}>
<Text style={pdfStyles.sectionTitle}>Professional Summary</Text>
<Text style={{ fontSize: 9, color: "#27272a" }}>{data.profile.summary}</Text>
minimal: StyleSheet.create({
page: {
padding: 36,
fontFamily: "Helvetica",
fontSize: 9,
color: "#18181b",
lineHeight: 1.4,
},
header: {
marginBottom: 14,
paddingBottom: 8,
borderBottomWidth: 1,
borderBottomColor: "#e4e4e7",
},
name: {
fontSize: 20,
fontFamily: "Helvetica-Bold",
color: "#09090b",
marginBottom: 2,
},
title: {
fontSize: 10,
color: "#52525b",
marginBottom: 4,
fontFamily: "Helvetica",
},
contact: {
fontSize: 8.5,
color: "#71717a",
},
section: {
marginBottom: 12,
},
sectionTitle: {
fontSize: 9,
fontFamily: "Helvetica-Bold",
color: "#09090b",
borderBottomWidth: 0.5,
borderBottomColor: "#e4e4e7",
paddingBottom: 2,
marginBottom: 6,
textTransform: "uppercase",
letterSpacing: 0.5,
},
jobHeader: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 2,
},
jobTitle: {
fontFamily: "Helvetica-Bold",
fontSize: 9.5,
},
company: {
color: "#52525b",
fontFamily: "Helvetica",
},
bullet: {
marginLeft: 8,
marginBottom: 2,
fontSize: 8.5,
color: "#27272a",
},
skillsRow: {
flexDirection: "row",
flexWrap: "wrap",
},
skillBadge: {
fontSize: 8.5,
color: "#27272a",
marginRight: 8,
},
}),
};
const ResumePDFDocument = ({ data, theme = "modern" }: { data: any; theme?: "modern" | "editorial" | "minimal" }) => {
const styles = themesPDF[theme] || themesPDF.modern;
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.name}>{data.profile?.name || "Applicant Name"}</Text>
{data.profile?.title && <Text style={styles.title}>{data.profile.title}</Text>}
<Text style={styles.contact}>
{[data.profile?.email, data.profile?.phone, data.profile?.location].filter(Boolean).join(" | ")}
</Text>
</View>
)}
{Array.isArray(data.work) && data.work.length > 0 && (
<View style={pdfStyles.section}>
<Text style={pdfStyles.sectionTitle}>Professional Experience</Text>
{data.work.map((item: any, i: number) => (
<View key={i} style={{ marginBottom: 8 }}>
<View style={pdfStyles.jobHeader}>
<Text style={pdfStyles.jobTitle}>
{item.position || "Position"}{" "}
{item.company ? <Text style={pdfStyles.company}>— {item.company}</Text> : null}
{/* Summary */}
{data.profile?.summary && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Professional Summary</Text>
<Text style={{ fontSize: theme === "editorial" ? 9.5 : 9, color: theme === "modern" ? "#334155" : "#27272a" }}>
{data.profile.summary}
</Text>
</View>
)}
{/* Experience */}
{Array.isArray(data.work) && data.work.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Professional Experience</Text>
{data.work.map((item: any, i: number) => (
<View key={i} style={{ marginBottom: 9 }}>
<View style={styles.jobHeader}>
<Text style={styles.jobTitle}>{item.position || "Position"}</Text>
{item.company && <Text style={styles.company}>{item.company}</Text>}
</View>
{item.summary && (
<Text style={{ marginBottom: 3, fontSize: 8.5, color: "#64748b" }}>
{item.summary}
</Text>
)}
{Array.isArray(item.highlights) &&
item.highlights.map((h: string, hIdx: number) => (
<Text key={hIdx} style={styles.bullet}>• {h}</Text>
))}
</View>
))}
</View>
)}
{/* Skills */}
{Array.isArray(data.skills) && data.skills.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Skills & Technical Proficiencies</Text>
<View style={styles.skillsRow}>
{theme === "modern" ? (
data.skills.map((s: string, idx: number) => (
<Text key={idx} style={styles.skillBadge}>{s}</Text>
))
) : (
<Text style={{ fontSize: 9 }}>{data.skills.join(" • ")}</Text>
)}
</View>
</View>
)}
{/* Education */}
{Array.isArray(data.education) && data.education.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Education & Certifications</Text>
{data.education.map((edu: any, i: number) => (
<View key={i} style={{ marginBottom: 4 }}>
<Text style={styles.jobTitle}>
{edu.degree || "Degree / Program"}
{edu.institution ? ` — ${edu.institution}` : ""}
</Text>
</View>
{item.summary && <Text style={{ marginBottom: 3, fontSize: 8.5 }}>{item.summary}</Text>}
{Array.isArray(item.highlights) &&
item.highlights.map((h: string, hIdx: number) => (
<Text key={hIdx} style={pdfStyles.bullet}>• {h}</Text>
))}
</View>
))}
</View>
)}
{Array.isArray(data.skills) && data.skills.length > 0 && (
<View style={pdfStyles.section}>
<Text style={pdfStyles.sectionTitle}>Skills & Competencies</Text>
<Text style={{ fontSize: 9 }}>{data.skills.join(" • ")}</Text>
</View>
)}
{Array.isArray(data.education) && data.education.length > 0 && (
<View style={pdfStyles.section}>
<Text style={pdfStyles.sectionTitle}>Education & Certifications</Text>
{data.education.map((edu: any, i: number) => (
<View key={i} style={{ marginBottom: 4 }}>
<Text style={pdfStyles.jobTitle}>
{edu.degree && edu.institution
? `${edu.degree} — ${edu.institution}`
: edu.degree || edu.institution || "High School / Technical Education"}
</Text>
</View>
))}
</View>
)}
</Page>
</Document>
);
))}
</View>
)}
</Page>
</Document>
);
};
export function ResumePreview({ data }: { data: any }) {
const [isClient, setIsClient] = useState(false);
const [theme, setTheme] = useState<"classic" | "modern" | "compact">("classic");
const [theme, setTheme] = useState<"modern" | "editorial" | "minimal">("modern");
useEffect(() => {
setIsClient(true);
@ -148,78 +347,147 @@ export function ResumePreview({ data }: { data: any }) {
const education = data.education || [];
return (
<div className="bg-white border border-zinc-200 rounded-md p-5 sticky top-16 shadow-sm">
<div className="flex items-center justify-between pb-3 border-b border-zinc-200 mb-4 flex-wrap gap-2">
<div className="bg-white border border-stone-300 rounded-sm p-5 sticky top-16 shadow-[2px_2px_0px_#1c1917]">
<div className="flex items-center justify-between pb-3 border-b border-stone-200 mb-4 flex-wrap gap-2">
<div className="flex items-center space-x-3">
<span className="text-xs font-mono font-bold text-zinc-900 uppercase tracking-wider">
Live ATS Preview
<span className="text-xs font-mono font-bold text-stone-900 uppercase tracking-wider">
ATS Visual Template
</span>
<select
value={theme}
onChange={(e) => setTheme(e.target.value as any)}
className="text-[11px] font-mono border border-zinc-200 rounded px-2 py-0.5 bg-zinc-50 text-zinc-800"
className="text-xs font-mono border border-stone-300 rounded-none px-2.5 py-1 bg-stone-50 text-stone-900 font-semibold focus:outline-none focus:ring-1 focus:ring-stone-900"
>
<option value="classic">Theme: Classic ATS</option>
<option value="modern">Theme: Modern Tech</option>
<option value="compact">Theme: Compact Executive</option>
<option value="modern">Modern Tech (Navy & Slate Accent)</option>
<option value="editorial">Executive Editorial (Warm Serif & Rust)</option>
<option value="minimal">Clean Monochrome (Standard ATS)</option>
</select>
</div>
{isClient && (
<PDFDownloadLink
document={<ResumePDFDocument data={data} />}
fileName={`${(profile.name || "Resume").replace(/\s+/g, "_")}_ATS.pdf`}
className="text-xs font-mono font-semibold bg-zinc-900 hover:bg-zinc-800 text-white px-3 py-1 rounded transition-colors inline-block"
document={<ResumePDFDocument data={data} theme={theme} />}
fileName={`${(profile.name || "Resume").replace(/\s+/g, "_")}_${theme.toUpperCase()}_ATS.pdf`}
className="text-xs font-mono font-semibold bg-stone-900 hover:bg-stone-800 text-stone-100 px-3.5 py-1.5 border border-stone-950 shadow-[1px_1px_0px_#000] transition-colors inline-block"
>
Download ATS PDF
Export Styled PDF
</PDFDownloadLink>
)}
</div>
{/* Styled Responsive Visual Document View */}
<div
className={`bg-white border p-6 rounded text-xs space-y-4 max-h-[680px] overflow-y-auto ${
className={`bg-white border p-7 text-xs space-y-5 max-h-[680px] overflow-y-auto transition-all ${
theme === "modern"
? "border-zinc-300 font-sans shadow-inner"
: theme === "compact"
? "border-zinc-200 font-mono text-[11px]"
? "border-slate-300 font-sans shadow-md"
: theme === "editorial"
? "border-amber-200/80 font-serif shadow-md bg-[#fffdfa]"
: "border-zinc-300 font-sans"
}`}
>
<div className="border-b pb-3 border-zinc-300">
<h2 className="text-lg font-bold text-zinc-900 uppercase tracking-tight">{profile.name || "Your Full Name"}</h2>
<p className="text-zinc-700 font-bold text-xs mt-0.5">{profile.title || "Target Job Title"}</p>
<div className="text-zinc-500 mt-1 flex flex-wrap gap-x-3 text-[11px] font-mono">
{/* Header Block */}
<div
className={`pb-4 ${
theme === "modern"
? "border-b-2 border-slate-900"
: theme === "editorial"
? "border-b border-stone-800 text-center"
: "border-b border-zinc-300"
}`}
>
<h2
className={`text-2xl font-bold tracking-tight ${
theme === "modern"
? "text-slate-950"
: theme === "editorial"
? "font-serif text-3xl text-stone-950"
: "text-zinc-950 uppercase"
}`}
>
{profile.name || "Your Full Name"}
</h2>
<p
className={`text-xs font-semibold mt-1 uppercase tracking-wider ${
theme === "modern"
? "text-blue-700 font-mono"
: theme === "editorial"
? "text-amber-800 italic normal-case text-sm"
: "text-zinc-600 font-mono"
}`}
>
{profile.title || "Target Professional Title"}
</p>
<div
className={`mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] font-mono ${
theme === "editorial" ? "justify-center text-stone-600" : "text-slate-600"
}`}
>
{profile.email && <span>{profile.email}</span>}
{profile.phone && <span>{profile.phone}</span>}
{profile.location && <span>{profile.location}</span>}
{profile.phone && <span>• {profile.phone}</span>}
{profile.location && <span>• {profile.location}</span>}
</div>
</div>
{/* Summary Block */}
{profile.summary && (
<div>
<h3 className="font-bold text-zinc-900 border-b border-zinc-200 pb-0.5 mb-1 uppercase text-[10px] tracking-wider font-mono">
Summary
<div className="space-y-1.5">
<h3
className={`font-mono text-[10px] font-bold uppercase tracking-wider pb-0.5 border-b ${
theme === "modern"
? "text-slate-900 border-slate-200"
: theme === "editorial"
? "text-stone-900 border-stone-300"
: "text-zinc-800 border-zinc-200"
}`}
>
Professional Summary
</h3>
<p className="leading-relaxed text-zinc-700">{profile.summary}</p>
<p className="leading-relaxed text-stone-700 text-xs">{profile.summary}</p>
</div>
)}
{/* Experience Block */}
{work.length > 0 && (
<div>
<h3 className="font-bold text-zinc-900 border-b border-zinc-200 pb-0.5 mb-2 uppercase text-[10px] tracking-wider font-mono">
<div className="space-y-2">
<h3
className={`font-mono text-[10px] font-bold uppercase tracking-wider pb-0.5 border-b ${
theme === "modern"
? "text-slate-900 border-slate-200"
: theme === "editorial"
? "text-stone-900 border-stone-300"
: "text-zinc-800 border-zinc-200"
}`}
>
Professional Experience
</h3>
<div className="space-y-3">
<div className="space-y-4">
{work.map((w: any, idx: number) => (
<div key={idx}>
<p className="font-bold text-zinc-900">
{w.position || "Position"}{" "}
{w.company ? <span className="text-zinc-600 font-medium">— {w.company}</span> : null}
</p>
{w.summary && <p className="text-zinc-700 mt-0.5 text-xs">{w.summary}</p>}
<div key={idx} className="space-y-1">
<div className="flex justify-between items-baseline flex-wrap gap-1">
<span className="font-bold text-slate-950 text-sm">{w.position || "Position"}</span>
{w.company && (
<span
className={`text-xs font-semibold ${
theme === "modern"
? "text-blue-700 font-mono"
: theme === "editorial"
? "text-amber-900 italic font-serif"
: "text-zinc-700 font-mono"
}`}
>
{w.company}
</span>
)}
</div>
{w.summary && <p className="text-slate-600 italic text-xs mb-1">{w.summary}</p>}
{Array.isArray(w.highlights) && w.highlights.length > 0 && (
<ul className="list-disc list-inside text-zinc-700 mt-1 space-y-0.5">
<ul className="list-disc list-outside ml-4 text-slate-700 space-y-1 text-xs leading-relaxed">
{w.highlights.map((h: string, hIdx: number) => (
<li key={hIdx}>{h}</li>
))}
@ -231,26 +499,61 @@ export function ResumePreview({ data }: { data: any }) {
</div>
)}
{/* Skills Block */}
{skills.length > 0 && (
<div>
<h3 className="font-bold text-zinc-900 border-b border-zinc-200 pb-0.5 mb-1.5 uppercase text-[10px] tracking-wider font-mono">
Skills & Competencies
<div className="space-y-2">
<h3
className={`font-mono text-[10px] font-bold uppercase tracking-wider pb-0.5 border-b ${
theme === "modern"
? "text-slate-900 border-slate-200"
: theme === "editorial"
? "text-stone-900 border-stone-300"
: "text-zinc-800 border-zinc-200"
}`}
>
Skills & Technical Proficiencies
</h3>
<p className="text-zinc-800 font-mono text-[11px] leading-relaxed">{skills.join(" • ")}</p>
{theme === "modern" ? (
<div className="flex flex-wrap gap-1.5 pt-0.5">
{skills.map((s: string, idx: number) => (
<span
key={idx}
className="bg-slate-100 border border-slate-200 text-slate-800 text-[11px] font-mono px-2 py-0.5 rounded-sm font-medium"
>
{s}
</span>
))}
</div>
) : (
<p className="text-slate-800 text-xs font-mono leading-relaxed">{skills.join(" • ")}</p>
)}
</div>
)}
{/* Education Block */}
{education.length > 0 && (
<div>
<h3 className="font-bold text-zinc-900 border-b border-zinc-200 pb-0.5 mb-1.5 uppercase text-[10px] tracking-wider font-mono">
<div className="space-y-1.5">
<h3
className={`font-mono text-[10px] font-bold uppercase tracking-wider pb-0.5 border-b ${
theme === "modern"
? "text-slate-900 border-slate-200"
: theme === "editorial"
? "text-stone-900 border-stone-300"
: "text-zinc-800 border-zinc-200"
}`}
>
Education & Certifications
</h3>
<div className="space-y-1">
{education.map((edu: any, idx: number) => (
<p key={idx} className="text-zinc-800">
<span className="font-bold">{edu.degree || edu.institution || "High School Diploma"}</span>
{edu.institution && edu.degree ? <span className="text-zinc-600"> — {edu.institution}</span> : null}
</p>
<div key={idx} className="text-xs text-slate-900">
<span className="font-bold">{edu.degree || "Degree / Program"}</span>
{edu.institution && (
<span className="text-slate-600 font-medium"> — {edu.institution}</span>
)}
</div>
))}
</div>
</div>