) => {
+ const newSkills = skillsWithIds.map((skill) =>
+ skill.id === id ? { ...skill, ...updates } : skill
+ );
+ setSkillsWithIds(newSkills);
+ onChange(newSkills.map(({ id, ...skill }) => skill));
+ };
+
+ const handleRemoveCategory = (id: string) => {
+ const newSkills = skillsWithIds.filter((skill) => skill.id !== id);
+ setSkillsWithIds(newSkills);
+ onChange(newSkills.map(({ id, ...skill }) => skill));
+ };
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ if (over && active.id !== over.id) {
+ const oldIndex = skillsWithIds.findIndex(
+ (skill) => skill.id === active.id
+ );
+ const newIndex = skillsWithIds.findIndex((skill) => skill.id === over.id);
+
+ const newSkills = arrayMove(skillsWithIds, oldIndex, newIndex);
+ setSkillsWithIds(newSkills);
+ onChange(newSkills.map(({ id, ...skill }) => skill));
+ }
+ };
+
+ return (
+
+
+
+
+
+ Skills
+
+
+
+ Add Category
+
+
+
+
+ {skillsWithIds.length === 0 ? (
+
+
+
No skill categories yet
+
+ Click "Add Category" to organize your skills
+
+
+ ) : (
+
+ s.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {skillsWithIds.map((skill, index) => (
+
+ {index > 0 && }
+
+
+ ))}
+
+
+
+ )}
+
+ {skillsWithIds.length > 0 && (
+ <>
+ {/* Warning for too few skills */}
+ {(() => {
+ const totalSkills = skillsWithIds.reduce(
+ (sum, skill) => sum + skill.items.length,
+ 0
+ );
+ return totalSkills < 5 ? (
+
+
+
+ You have {totalSkills} skill{totalSkills !== 1 ? 's' : ''} listed. Consider adding more skills to better showcase your capabilities (aim for at least 5-10).
+
+
+ ) : null;
+ })()}
+
+
+
+ 💡 Tip: Drag categories to reorder them. Organize skills by type
+ (e.g., Programming Languages, Frameworks, Tools, Soft Skills)
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/components/resume/templates/ClassicTemplate.tsx b/components/resume/templates/ClassicTemplate.tsx
new file mode 100644
index 000000000..99f1e537f
--- /dev/null
+++ b/components/resume/templates/ClassicTemplate.tsx
@@ -0,0 +1,283 @@
+'use client';
+
+import { Resume, PersonalInfo, Education, Experience, Project, Skill, Certification, Award, CustomContent } from '@/types/resume';
+
+interface ClassicTemplateProps {
+ resume: Resume;
+}
+
+export function ClassicTemplate({ resume }: ClassicTemplateProps) {
+ const { styling } = resume;
+
+ // Get personal info section
+ const personalInfoSection = resume.sections.find(s => s.type === 'personal_info');
+ const personalInfo = personalInfoSection?.content as PersonalInfo | undefined;
+
+ // Get all visible sections sorted by order
+ const visibleSections = resume.sections
+ .filter(s => s.visible && s.type !== 'personal_info')
+ .sort((a, b) => a.order - b.order);
+
+ // Render section content based on type
+ const renderSectionContent = (section: { type: string; content: unknown; visible: boolean }) => {
+ if (!section.visible) return null;
+
+ switch (section.type) {
+ case 'education':
+ return renderEducation(section.content as Education[]);
+ case 'experience':
+ return renderExperience(section.content as Experience[]);
+ case 'projects':
+ return renderProjects(section.content as Project[]);
+ case 'skills':
+ return renderSkills(section.content as Skill[]);
+ case 'certifications':
+ return renderCertifications(section.content as Certification[]);
+ case 'awards':
+ return renderAwards(section.content as Award[]);
+ case 'custom':
+ return renderCustom(section.content as CustomContent);
+ default:
+ return null;
+ }
+ };
+
+ const renderPersonalInfo = (content: PersonalInfo) => {
+ return (
+
+ {content.full_name && (
+
+ {content.full_name}
+
+ )}
+
+ {content.email && {content.email} }
+ {content.phone && • }
+ {content.phone && {content.phone} }
+ {content.location && • }
+ {content.location && {content.location} }
+
+ {(content.website || content.linkedin || content.github) && (
+
+ {content.website && {content.website} }
+ {content.linkedin && content.website && • }
+ {content.linkedin && {content.linkedin} }
+ {content.github && (content.website || content.linkedin) && • }
+ {content.github && {content.github} }
+
+ )}
+ {content.summary && (
+
+ {content.summary}
+
+ )}
+
+ );
+ };
+
+ const renderEducation = (items: Education[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
+
{item.institution}
+
+ {item.degree} in {item.field}
+
+
+
+
+ {item.start_date} - {item.current ? 'Present' : item.end_date}
+
+ {item.gpa &&
GPA: {item.gpa}
}
+
+
+ {item.achievements && item.achievements.length > 0 && (
+
+ {item.achievements.map((achievement, idx) => (
+ {achievement}
+ ))}
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderExperience = (items: Experience[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
+
{item.position}
+
{item.company}, {item.location}
+
+
+ {item.start_date} - {item.current ? 'Present' : item.end_date}
+
+
+ {item.description && (
+
{item.description}
+ )}
+ {item.achievements && item.achievements.length > 0 && (
+
+ {item.achievements.map((achievement, idx) => (
+ {achievement}
+ ))}
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderProjects = (items: Project[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
{item.name}
+ {(item.start_date || item.end_date) && (
+
+ {item.start_date} {item.end_date && `- ${item.end_date}`}
+
+ )}
+
+ {item.description && (
+
{item.description}
+ )}
+ {item.technologies && item.technologies.length > 0 && (
+
+ Technologies: {item.technologies.join(', ')}
+
+ )}
+ {(item.url || item.github) && (
+
+ {item.url && URL: {item.url} }
+ {item.github && GitHub: {item.github} }
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderSkills = (items: Skill[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((skill, idx) => (
+
+ {skill.category}: {' '}
+ {skill.items.join(', ')}
+
+ ))}
+
+ );
+ };
+
+ const renderCertifications = (items: Certification[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
+
{item.name}
+
{item.issuer}
+
+
{item.date}
+
+ {item.credential_id && (
+
Credential ID: {item.credential_id}
+ )}
+ {item.url && (
+
{item.url}
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderAwards = (items: Award[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
+
{item.title}
+
{item.issuer}
+
+
{item.date}
+
+ {item.description && (
+
{item.description}
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderCustom = (content: CustomContent) => {
+ if (!content.content) return null;
+ return (
+
+ );
+ };
+
+ return (
+
+ {/* Header with Personal Info */}
+ {personalInfo && renderPersonalInfo(personalInfo)}
+
+ {/* Single-column layout for all sections */}
+
+ {visibleSections.map((section) => (
+
+
+
+ {section.title}
+
+
+ {renderSectionContent(section)}
+
+ ))}
+
+ {/* Empty state */}
+ {visibleSections.length === 0 && (
+
+
+ Add sections to your resume to see them here
+
+
+ )}
+
+
+ );
+}
diff --git a/components/resume/templates/CreativeTemplate.tsx b/components/resume/templates/CreativeTemplate.tsx
new file mode 100644
index 000000000..4dc140ffa
--- /dev/null
+++ b/components/resume/templates/CreativeTemplate.tsx
@@ -0,0 +1,467 @@
+'use client';
+
+import { Resume, PersonalInfo, Education, Experience, Project, Skill, Certification, Award, CustomContent } from '@/types/resume';
+
+interface CreativeTemplateProps {
+ resume: Resume;
+}
+
+/**
+ * CreativeTemplate - Bold, colorful layout with unique asymmetric structure
+ * Design principles:
+ * - Vibrant color palette with gradients
+ * - Asymmetric layout with visual interest
+ * - Icons and visual elements throughout
+ * - Creative use of shapes and backgrounds
+ * - Bold typography with varied sizes
+ */
+export function CreativeTemplate({ resume }: CreativeTemplateProps) {
+ const { styling } = resume;
+
+ // Get personal info section
+ const personalInfoSection = resume.sections.find(s => s.type === 'personal_info');
+ const personalInfo = personalInfoSection?.content as PersonalInfo | undefined;
+
+ // Get all visible sections sorted by order
+ const visibleSections = resume.sections
+ .filter(s => s.visible && s.type !== 'personal_info')
+ .sort((a, b) => a.order - b.order);
+
+ // Render section content based on type
+ const renderSectionContent = (section: { type: string; content: unknown; visible: boolean }) => {
+ if (!section.visible) return null;
+
+ switch (section.type) {
+ case 'education':
+ return renderEducation(section.content as Education[]);
+ case 'experience':
+ return renderExperience(section.content as Experience[]);
+ case 'projects':
+ return renderProjects(section.content as Project[]);
+ case 'skills':
+ return renderSkills(section.content as Skill[]);
+ case 'certifications':
+ return renderCertifications(section.content as Certification[]);
+ case 'awards':
+ return renderAwards(section.content as Award[]);
+ case 'custom':
+ return renderCustom(section.content as CustomContent);
+ default:
+ return null;
+ }
+ };
+
+ const renderPersonalInfo = (content: PersonalInfo) => {
+ return (
+
+ {/* Colorful background with geometric shapes */}
+
+
+
+
+ {/* Content */}
+
+ {content.full_name && (
+
+ {content.full_name}
+
+ )}
+
+ {/* Contact info with icons */}
+
+ {content.email && (
+
+ ✉️
+ {content.email}
+
+ )}
+ {content.phone && (
+
+ 📱
+ {content.phone}
+
+ )}
+ {content.location && (
+
+ 📍
+ {content.location}
+
+ )}
+
+
+ {/* Links with icons */}
+
+ {content.website && (
+
+ 🌐
+ {content.website}
+
+ )}
+ {content.linkedin && (
+
+ 💼
+ {content.linkedin}
+
+ )}
+ {content.github && (
+
+ ⚡
+ {content.github}
+
+ )}
+
+
+ {/* Summary */}
+ {content.summary && (
+
+ {content.summary}
+
+ )}
+
+
+ );
+ };
+
+ const renderEducation = (items: Education[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item, index) => (
+
+ {/* Decorative circle */}
+
+
+
+
+
+ 🎓
+
{item.institution}
+
+
+ {item.degree} in {item.field}
+
+
+
+
+
+ {item.start_date} - {item.current ? 'Present' : item.end_date}
+
+
+ {item.gpa && (
+
GPA: {item.gpa}
+ )}
+
+
+ {item.achievements && item.achievements.length > 0 && (
+
+ {item.achievements.map((achievement, idx) => (
+
+ ★
+ {achievement}
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderExperience = (items: Experience[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item, index) => (
+
+ {/* Decorative circle */}
+
+
+
+
+
+ 💼
+
{item.position}
+
+
+ {item.company} • {item.location}
+
+
+
+
+ {item.start_date} - {item.current ? 'Present' : item.end_date}
+
+
+
+ {item.description && (
+
{item.description}
+ )}
+ {item.achievements && item.achievements.length > 0 && (
+
+ {item.achievements.map((achievement, idx) => (
+
+ ★
+ {achievement}
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderProjects = (items: Project[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item, index) => (
+
+ {/* Decorative circle */}
+
+
+
+
+ 🚀
+
{item.name}
+
+ {(item.start_date || item.end_date) && (
+
+
+ {item.start_date} {item.end_date && `- ${item.end_date}`}
+
+
+ )}
+
+ {item.description && (
+
{item.description}
+ )}
+ {item.technologies && item.technologies.length > 0 && (
+
+ {item.technologies.map((tech, idx) => (
+
+ {tech}
+
+ ))}
+
+ )}
+ {(item.url || item.github) && (
+
+ {item.url && (
+
+ 🔗
+ {item.url}
+
+ )}
+ {item.github && (
+
+ ⚡
+ {item.github}
+
+ )}
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderSkills = (items: Skill[]) => {
+ if (!items || items.length === 0) return null;
+
+ // Color schemes for different skill categories
+ const colorSchemes = [
+ 'from-red-400 to-orange-400',
+ 'from-blue-400 to-cyan-400',
+ 'from-purple-400 to-pink-400',
+ 'from-green-400 to-emerald-400',
+ 'from-yellow-400 to-amber-400',
+ 'from-indigo-400 to-violet-400',
+ ];
+
+ return (
+
+ {items.map((skill, idx) => (
+
+
+ ⚡
+
+ {skill.category}
+
+
+
+ {skill.items.map((item, itemIdx) => (
+
+ {item}
+
+ ))}
+
+
+ ))}
+
+ );
+ };
+
+ const renderCertifications = (items: Certification[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item, index) => (
+
+ {/* Decorative circle */}
+
+
+
+
+
+ 🏆
+
{item.name}
+
+
{item.issuer}
+
+
+
+ {item.credential_id && (
+
+ ID: {item.credential_id}
+
+ )}
+ {item.url && (
+
+ 🔗
+ {item.url}
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderAwards = (items: Award[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item, index) => (
+
+ {/* Decorative circle */}
+
+
+
+
+
+ 🌟
+
{item.title}
+
+
{item.issuer}
+
+
+
+ {item.description && (
+
{item.description}
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderCustom = (content: CustomContent) => {
+ if (!content.content) return null;
+ return (
+
+
+ {content.content}
+
+
+ );
+ };
+
+ return (
+
+ {/* Colorful header with personal info */}
+ {personalInfo && renderPersonalInfo(personalInfo)}
+
+ {/* Asymmetric layout - alternating section styles */}
+
+ {visibleSections.map((section, index) => {
+ // Alternate between different visual styles for asymmetry
+ const isEven = index % 2 === 0;
+
+ return (
+
+ {/* Section header with colorful gradient and icon */}
+
+
+
+ {section.title}
+
+
+
+
+
+ {/* Section content */}
+
+ {renderSectionContent(section)}
+
+
+ );
+ })}
+
+ {/* Empty state with colorful design */}
+ {visibleSections.length === 0 && (
+
+
+
+ ✨ Add sections to your resume to see them here ✨
+
+
+
+ )}
+
+
+ );
+}
diff --git a/components/resume/templates/ExecutiveTemplate.tsx b/components/resume/templates/ExecutiveTemplate.tsx
new file mode 100644
index 000000000..d71143c2a
--- /dev/null
+++ b/components/resume/templates/ExecutiveTemplate.tsx
@@ -0,0 +1,432 @@
+'use client';
+
+import { Resume, PersonalInfo, Education, Experience, Project, Skill, Certification, Award, CustomContent } from '@/types/resume';
+
+interface ExecutiveTemplateProps {
+ resume: Resume;
+}
+
+/**
+ * ExecutiveTemplate - Professional layout for senior positions
+ * Design principles:
+ * - Elegant, sophisticated typography with refined spacing
+ * - Subtle branding elements and professional color palette
+ * - Clean hierarchy emphasizing leadership and achievements
+ * - Premium feel with refined details and balanced whitespace
+ * - Emphasis on impact and executive presence
+ */
+export function ExecutiveTemplate({ resume }: ExecutiveTemplateProps) {
+ const { styling } = resume;
+
+ // Get personal info section
+ const personalInfoSection = resume.sections.find(s => s.type === 'personal_info');
+ const personalInfo = personalInfoSection?.content as PersonalInfo | undefined;
+
+ // Get all visible sections sorted by order
+ const visibleSections = resume.sections
+ .filter(s => s.visible && s.type !== 'personal_info')
+ .sort((a, b) => a.order - b.order);
+
+ // Render section content based on type
+ const renderSectionContent = (section: { type: string; content: unknown; visible: boolean }) => {
+ if (!section.visible) return null;
+
+ switch (section.type) {
+ case 'education':
+ return renderEducation(section.content as Education[]);
+ case 'experience':
+ return renderExperience(section.content as Experience[]);
+ case 'projects':
+ return renderProjects(section.content as Project[]);
+ case 'skills':
+ return renderSkills(section.content as Skill[]);
+ case 'certifications':
+ return renderCertifications(section.content as Certification[]);
+ case 'awards':
+ return renderAwards(section.content as Award[]);
+ case 'custom':
+ return renderCustom(section.content as CustomContent);
+ default:
+ return null;
+ }
+ };
+
+ const renderPersonalInfo = (content: PersonalInfo) => {
+ return (
+
+ {/* Name with elegant typography */}
+ {content.full_name && (
+
+ {content.full_name}
+
+ )}
+
+ {/* Contact information in refined layout */}
+
+ {content.email && (
+
+
+ {content.email}
+
+ )}
+ {content.phone && (
+
+
+ {content.phone}
+
+ )}
+ {content.location && (
+
+
+ {content.location}
+
+ )}
+
+
+ {/* Professional links */}
+ {(content.website || content.linkedin || content.github) && (
+
+ {content.website && (
+
+
+ {content.website}
+
+ )}
+ {content.linkedin && (
+
+
+ {content.linkedin}
+
+ )}
+ {content.github && (
+
+
+ {content.github}
+
+ )}
+
+ )}
+
+ {/* Executive summary with emphasis */}
+ {content.summary && (
+
+
+ {content.summary}
+
+
+ )}
+
+ );
+ };
+
+ const renderEducation = (items: Education[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+ {/* Subtle accent line */}
+
+
+
+
+
+
+ {item.degree} in {item.field}
+
+
{item.institution}
+
+
+
+ {item.start_date} – {item.current ? 'Present' : item.end_date}
+
+ {item.gpa && (
+
GPA: {item.gpa}
+ )}
+
+
+ {item.achievements && item.achievements.length > 0 && (
+
+ {item.achievements.map((achievement, idx) => (
+
+ ▪
+ {achievement}
+
+ ))}
+
+ )}
+
+
+ ))}
+
+ );
+ };
+
+ const renderExperience = (items: Experience[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+ {/* Subtle accent line */}
+
+
+
+
+
+
+ {item.position}
+
+
+ {item.company} · {item.location}
+
+
+
+ {item.start_date} – {item.current ? 'Present' : item.end_date}
+
+
+ {item.description && (
+
+ {item.description}
+
+ )}
+ {item.achievements && item.achievements.length > 0 && (
+
+ {item.achievements.map((achievement, idx) => (
+
+ ▪
+ {achievement}
+
+ ))}
+
+ )}
+
+
+ ))}
+
+ );
+ };
+
+ const renderProjects = (items: Project[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+ {/* Subtle accent line */}
+
+
+
+
+
+ {item.name}
+
+ {(item.start_date || item.end_date) && (
+
+ {item.start_date} {item.end_date && `– ${item.end_date}`}
+
+ )}
+
+ {item.description && (
+
{item.description}
+ )}
+ {item.technologies && item.technologies.length > 0 && (
+
+ {item.technologies.map((tech, idx) => (
+
+ {tech}
+
+ ))}
+
+ )}
+ {(item.url || item.github) && (
+
+ {item.url && (
+
+
+ {item.url}
+
+ )}
+ {item.github && (
+
+
+ {item.github}
+
+ )}
+
+ )}
+
+
+ ))}
+
+ );
+ };
+
+ const renderSkills = (items: Skill[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((skill, idx) => (
+
+ {/* Subtle accent marker */}
+
+
+
+
+ {skill.category}
+
+
+ {skill.items.map((item, itemIdx) => (
+
+ {item}
+
+ ))}
+
+
+
+ ))}
+
+ );
+ };
+
+ const renderCertifications = (items: Certification[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+ {/* Subtle accent line */}
+
+
+
+
+
+
+ {item.name}
+
+
{item.issuer}
+
+
{item.date}
+
+ {item.credential_id && (
+
Credential: {item.credential_id}
+ )}
+ {item.url && (
+
+
+ {item.url}
+
+ )}
+
+
+ ))}
+
+ );
+ };
+
+ const renderAwards = (items: Award[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+ {/* Subtle accent line */}
+
+
+
+
+
+
+ {item.title}
+
+
{item.issuer}
+
+
{item.date}
+
+ {item.description && (
+
{item.description}
+ )}
+
+
+ ))}
+
+ );
+ };
+
+ const renderCustom = (content: CustomContent) => {
+ if (!content.content) return null;
+ return (
+
+
+
+ {content.content}
+
+
+ );
+ };
+
+ return (
+
+ {/* Header with personal info - elegant and refined */}
+ {personalInfo && renderPersonalInfo(personalInfo)}
+
+ {/* Single-column layout with sophisticated spacing */}
+
+ {visibleSections.map((section) => (
+
+ {/* Section header with subtle branding element */}
+
+
+ {/* Subtle branding accent */}
+
+
+ {section.title}
+
+
+ {/* Refined underline */}
+
+
+
+ {/* Section content */}
+ {renderSectionContent(section)}
+
+ ))}
+
+ {/* Empty state with refined styling */}
+ {visibleSections.length === 0 && (
+
+
+
+ Add sections to your resume to see them here
+
+
+
+ )}
+
+
+ {/* Subtle footer branding element */}
+
+
+ );
+}
diff --git a/components/resume/templates/MinimalTemplate.tsx b/components/resume/templates/MinimalTemplate.tsx
new file mode 100644
index 000000000..c334d647d
--- /dev/null
+++ b/components/resume/templates/MinimalTemplate.tsx
@@ -0,0 +1,309 @@
+'use client';
+
+import { Resume, PersonalInfo, Education, Experience, Project, Skill, Certification, Award, CustomContent } from '@/types/resume';
+
+interface MinimalTemplateProps {
+ resume: Resume;
+}
+
+/**
+ * MinimalTemplate - Ultra-clean layout with maximum whitespace
+ * Design principles:
+ * - Maximum whitespace for breathing room
+ * - Minimal colors (black text on white background)
+ * - Simple, clean typography
+ * - Clear content hierarchy through spacing and font sizes
+ * - No decorative elements or borders
+ */
+export function MinimalTemplate({ resume }: MinimalTemplateProps) {
+ const { styling } = resume;
+
+ // Get personal info section
+ const personalInfoSection = resume.sections.find(s => s.type === 'personal_info');
+ const personalInfo = personalInfoSection?.content as PersonalInfo | undefined;
+
+ // Get all visible sections sorted by order
+ const visibleSections = resume.sections
+ .filter(s => s.visible && s.type !== 'personal_info')
+ .sort((a, b) => a.order - b.order);
+
+ // Render section content based on type
+ const renderSectionContent = (section: { type: string; content: unknown; visible: boolean }) => {
+ if (!section.visible) return null;
+
+ switch (section.type) {
+ case 'education':
+ return renderEducation(section.content as Education[]);
+ case 'experience':
+ return renderExperience(section.content as Experience[]);
+ case 'projects':
+ return renderProjects(section.content as Project[]);
+ case 'skills':
+ return renderSkills(section.content as Skill[]);
+ case 'certifications':
+ return renderCertifications(section.content as Certification[]);
+ case 'awards':
+ return renderAwards(section.content as Award[]);
+ case 'custom':
+ return renderCustom(section.content as CustomContent);
+ default:
+ return null;
+ }
+ };
+
+ const renderPersonalInfo = (content: PersonalInfo) => {
+ return (
+
+ {content.full_name && (
+
+ {content.full_name}
+
+ )}
+
+ {/* Contact information in a clean, minimal layout */}
+
+ {content.email && (
+
{content.email}
+ )}
+ {content.phone && (
+
{content.phone}
+ )}
+ {content.location && (
+
{content.location}
+ )}
+ {content.website && (
+
{content.website}
+ )}
+ {content.linkedin && (
+
{content.linkedin}
+ )}
+ {content.github && (
+
{content.github}
+ )}
+
+
+ {/* Summary with extra spacing */}
+ {content.summary && (
+
+ {content.summary}
+
+ )}
+
+ );
+ };
+
+ const renderEducation = (items: Education[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
+
+ {item.degree} in {item.field}
+
+
{item.institution}
+
+
+
+ {item.start_date} – {item.current ? 'Present' : item.end_date}
+
+ {item.gpa &&
{item.gpa}
}
+
+
+ {item.achievements && item.achievements.length > 0 && (
+
+ {item.achievements.map((achievement, idx) => (
+
+ {achievement}
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderExperience = (items: Experience[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
+
{item.position}
+
+ {item.company} · {item.location}
+
+
+
+ {item.start_date} – {item.current ? 'Present' : item.end_date}
+
+
+ {item.description && (
+
{item.description}
+ )}
+ {item.achievements && item.achievements.length > 0 && (
+
+ {item.achievements.map((achievement, idx) => (
+
+ {achievement}
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderProjects = (items: Project[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
{item.name}
+ {(item.start_date || item.end_date) && (
+
+ {item.start_date} {item.end_date && `– ${item.end_date}`}
+
+ )}
+
+ {item.description && (
+
{item.description}
+ )}
+ {item.technologies && item.technologies.length > 0 && (
+
+ {item.technologies.join(' · ')}
+
+ )}
+ {(item.url || item.github) && (
+
+ {item.url &&
{item.url}
}
+ {item.github &&
{item.github}
}
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderSkills = (items: Skill[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((skill, idx) => (
+
+
{skill.category}
+
+ {skill.items.join(' · ')}
+
+
+ ))}
+
+ );
+ };
+
+ const renderCertifications = (items: Certification[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
+
{item.name}
+
{item.issuer}
+
+
{item.date}
+
+ {item.credential_id && (
+
{item.credential_id}
+ )}
+ {item.url && (
+
{item.url}
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderAwards = (items: Award[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
+
{item.title}
+
{item.issuer}
+
+
{item.date}
+
+ {item.description && (
+
{item.description}
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderCustom = (content: CustomContent) => {
+ if (!content.content) return null;
+ return (
+
+
+ {content.content}
+
+
+ );
+ };
+
+ return (
+
+ {/* Header with Personal Info - Maximum whitespace */}
+ {personalInfo && renderPersonalInfo(personalInfo)}
+
+ {/* Single-column layout with generous spacing */}
+
+ {visibleSections.map((section) => (
+
+ {/* Section title with minimal styling - just uppercase and spacing */}
+
+ {section.title}
+
+ {renderSectionContent(section)}
+
+ ))}
+
+ {/* Empty state */}
+ {visibleSections.length === 0 && (
+
+
+ Add sections to your resume to see them here
+
+
+ )}
+
+
+ );
+}
diff --git a/components/resume/templates/ModernTemplate.tsx b/components/resume/templates/ModernTemplate.tsx
new file mode 100644
index 000000000..15c0b2487
--- /dev/null
+++ b/components/resume/templates/ModernTemplate.tsx
@@ -0,0 +1,413 @@
+'use client';
+
+import { Resume, PersonalInfo, Education, Experience, Project, Skill, Certification, Award, CustomContent } from '@/types/resume';
+
+interface ModernTemplateProps {
+ resume: Resume;
+}
+
+export function ModernTemplate({ resume }: ModernTemplateProps) {
+ const { styling } = resume;
+
+ // Create CSS variables from styling
+ const styleVars = {
+ '--resume-font-family': styling.font_family,
+ '--resume-font-size-body': `${styling.font_size_body}pt`,
+ '--resume-font-size-heading': `${styling.font_size_heading}pt`,
+ '--resume-color-primary': styling.color_primary,
+ '--resume-color-text': styling.color_text,
+ '--resume-color-accent': styling.color_accent,
+ '--resume-line-height': styling.line_height,
+ '--resume-section-spacing': `${styling.section_spacing}rem`,
+ } as React.CSSProperties;
+ // Get personal info section
+ const personalInfoSection = resume.sections.find(s => s.type === 'personal_info');
+ const personalInfo = personalInfoSection?.content as PersonalInfo | undefined;
+
+ // Get skills section for sidebar
+ const skillsSection = resume.sections.find(s => s.type === 'skills' && s.visible);
+ const skills = skillsSection?.content as Skill[] | undefined;
+
+ // Get other sections for main content
+ const mainSections = resume.sections.filter(
+ s => s.visible && s.type !== 'personal_info' && s.type !== 'skills'
+ ).sort((a, b) => a.order - b.order);
+
+ // Render section content based on type
+ const renderSectionContent = (section: { type: string; content: unknown; visible: boolean }) => {
+ if (!section.visible) return null;
+
+ switch (section.type) {
+ case 'education':
+ return renderEducation(section.content as Education[]);
+ case 'experience':
+ return renderExperience(section.content as Experience[]);
+ case 'projects':
+ return renderProjects(section.content as Project[]);
+ case 'certifications':
+ return renderCertifications(section.content as Certification[]);
+ case 'awards':
+ return renderAwards(section.content as Award[]);
+ case 'custom':
+ return renderCustom(section.content as CustomContent);
+ default:
+ return null;
+ }
+ };
+
+ const renderPersonalInfo = (content: PersonalInfo) => {
+ return (
+
+ {content.full_name && (
+
+ {content.full_name}
+
+ )}
+
+ {content.email && (
+
+ ✉
+ {content.email}
+
+ )}
+ {content.phone && (
+
+ ☎
+ {content.phone}
+
+ )}
+ {content.location && (
+
+ 📍
+ {content.location}
+
+ )}
+
+
+ {content.website && (
+
+ 🌐
+ {content.website}
+
+ )}
+ {content.linkedin && (
+
+ in
+ {content.linkedin}
+
+ )}
+ {content.github && (
+
+ ⚡
+ {content.github}
+
+ )}
+
+ {content.summary && (
+
+ {content.summary}
+
+ )}
+
+ );
+ };
+
+ const renderEducation = (items: Education[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
+
{item.institution}
+
+ {item.degree} in {item.field}
+
+
+
+
+ {item.start_date} - {item.current ? 'Present' : item.end_date}
+
+ {item.gpa &&
GPA: {item.gpa}
}
+
+
+ {item.achievements && item.achievements.length > 0 && (
+
+ {item.achievements.map((achievement, idx) => (
+
+ ▸
+ {achievement}
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderExperience = (items: Experience[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
+
{item.position}
+
{item.company} • {item.location}
+
+
+ {item.start_date} - {item.current ? 'Present' : item.end_date}
+
+
+ {item.description && (
+
{item.description}
+ )}
+ {item.achievements && item.achievements.length > 0 && (
+
+ {item.achievements.map((achievement, idx) => (
+
+ ▸
+ {achievement}
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderProjects = (items: Project[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
{item.name}
+ {(item.start_date || item.end_date) && (
+
+ {item.start_date} {item.end_date && `- ${item.end_date}`}
+
+ )}
+
+ {item.description && (
+
{item.description}
+ )}
+ {item.technologies && item.technologies.length > 0 && (
+
+ {item.technologies.map((tech, idx) => (
+
+ {tech}
+
+ ))}
+
+ )}
+ {(item.url || item.github) && (
+
+ {item.url && 🔗 {item.url} }
+ {item.github && ⚡ {item.github} }
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderSkills = (items: Skill[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((skill, idx) => (
+
+
{skill.category}
+
+ {skill.items.map((item, itemIdx) => (
+
+ {item}
+
+ ))}
+
+
+ ))}
+
+ );
+ };
+
+ const renderCertifications = (items: Certification[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
+
{item.name}
+
{item.issuer}
+
+
{item.date}
+
+ {item.credential_id && (
+
ID: {item.credential_id}
+ )}
+ {item.url && (
+
🔗 {item.url}
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderAwards = (items: Award[]) => {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map((item) => (
+
+
+
+
{item.title}
+
{item.issuer}
+
+
{item.date}
+
+ {item.description && (
+
{item.description}
+ )}
+
+ ))}
+
+ );
+ };
+
+ const renderCustom = (content: CustomContent) => {
+ if (!content.content) return null;
+ return (
+
+ );
+ };
+
+ return (
+
+ {/* Header with Personal Info */}
+ {personalInfo && renderPersonalInfo(personalInfo)}
+
+ {/* Two-column layout: Main content + Sidebar */}
+
+ {/* Main Content - 2/3 width */}
+
+ {mainSections.map((section) => (
+
+
+
+ {section.title}
+
+
+
+ {renderSectionContent(section)}
+
+ ))}
+
+ {/* Empty state for main content */}
+ {mainSections.length === 0 && !skillsSection && (
+
+
+ Add sections to your resume to see them here
+
+
+ )}
+
+
+ {/* Sidebar - 1/3 width for Skills and Contact */}
+ {skillsSection && (
+
+ {/* Skills Section in Sidebar */}
+
+
+
+ {skillsSection.title}
+
+
+
+ {renderSkills(skills || [])}
+
+
+ {/* Contact Info Card in Sidebar */}
+ {personalInfo && (personalInfo.email || personalInfo.phone || personalInfo.location) && (
+
+
+
+ {personalInfo.email && (
+
+ ✉
+ {personalInfo.email}
+
+ )}
+ {personalInfo.phone && (
+
+ ☎
+ {personalInfo.phone}
+
+ )}
+ {personalInfo.location && (
+
+ 📍
+ {personalInfo.location}
+
+ )}
+ {personalInfo.website && (
+
+ 🌐
+ {personalInfo.website}
+
+ )}
+ {personalInfo.linkedin && (
+
+ in
+ {personalInfo.linkedin}
+
+ )}
+ {personalInfo.github && (
+
+ ⚡
+ {personalInfo.github}
+
+ )}
+
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/components/resume/templates/TemplateRenderer.tsx b/components/resume/templates/TemplateRenderer.tsx
new file mode 100644
index 000000000..7d5178e7e
--- /dev/null
+++ b/components/resume/templates/TemplateRenderer.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import { Resume } from '@/types/resume';
+import { useEffect, useState, lazy, Suspense } from 'react';
+
+// Lazy load template components for better performance
+const ModernTemplate = lazy(() => import('./ModernTemplate').then(m => ({ default: m.ModernTemplate })));
+const ClassicTemplate = lazy(() => import('./ClassicTemplate').then(m => ({ default: m.ClassicTemplate })));
+const MinimalTemplate = lazy(() => import('./MinimalTemplate').then(m => ({ default: m.MinimalTemplate })));
+const CreativeTemplate = lazy(() => import('./CreativeTemplate').then(m => ({ default: m.CreativeTemplate })));
+const ExecutiveTemplate = lazy(() => import('./ExecutiveTemplate').then(m => ({ default: m.ExecutiveTemplate })));
+
+// Template registry mapping template IDs to their lazy-loaded components
+const TEMPLATES: Record> = {
+ modern: ModernTemplate,
+ classic: ClassicTemplate,
+ minimal: MinimalTemplate,
+ creative: CreativeTemplate,
+ executive: ExecutiveTemplate,
+};
+
+// Default template if none is specified or template ID is invalid
+const DEFAULT_TEMPLATE = 'modern';
+
+interface TemplateRendererProps {
+ resume: Resume | null;
+}
+
+export function TemplateRenderer({ resume }: TemplateRendererProps) {
+ const [isTransitioning, setIsTransitioning] = useState(false);
+ const [currentTemplateId, setCurrentTemplateId] = useState(DEFAULT_TEMPLATE);
+
+ // Handle template switching with animation
+ useEffect(() => {
+ if (!resume) return;
+
+ const newTemplateId = resume.template_id || DEFAULT_TEMPLATE;
+
+ // If template changed, trigger transition animation
+ if (newTemplateId !== currentTemplateId) {
+ setIsTransitioning(true);
+
+ // Update template after brief fade out
+ const timeoutId = setTimeout(() => {
+ setCurrentTemplateId(newTemplateId);
+ setIsTransitioning(false);
+ }, 150);
+
+ return () => clearTimeout(timeoutId);
+ }
+ }, [resume, currentTemplateId]);
+
+ // Return null if no resume is provided
+ if (!resume) {
+ return null;
+ }
+
+ // Get the template component, fallback to default if not found
+ const TemplateComponent = TEMPLATES[currentTemplateId] || TEMPLATES[DEFAULT_TEMPLATE];
+
+ // Validate that we have a valid template component
+ if (!TemplateComponent) {
+ console.error(`Template "${currentTemplateId}" not found, using default template`);
+ const DefaultTemplate = TEMPLATES[DEFAULT_TEMPLATE];
+ return ;
+ }
+
+ return (
+
+ Loading template...
+
+ }>
+
+
+
+
+ );
+}
diff --git a/components/resume/templates/index.ts b/components/resume/templates/index.ts
new file mode 100644
index 000000000..fcde349c8
--- /dev/null
+++ b/components/resume/templates/index.ts
@@ -0,0 +1,6 @@
+export { TemplateRenderer } from './TemplateRenderer';
+export { ModernTemplate } from './ModernTemplate';
+export { ClassicTemplate } from './ClassicTemplate';
+export { MinimalTemplate } from './MinimalTemplate';
+export { CreativeTemplate } from './CreativeTemplate';
+export { ExecutiveTemplate } from './ExecutiveTemplate';
diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx
new file mode 100644
index 000000000..69e522319
--- /dev/null
+++ b/components/ui/alert-dialog.tsx
@@ -0,0 +1,141 @@
+'use client';
+
+import * as React from 'react';
+import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
+
+import { cn } from '@/lib/utils';
+import { buttonVariants } from '@/components/ui/button';
+
+const AlertDialog = AlertDialogPrimitive.Root;
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal;
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogHeader.displayName = 'AlertDialogHeader';
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogFooter.displayName = 'AlertDialogFooter';
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName;
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx
new file mode 100644
index 000000000..ab19d576f
--- /dev/null
+++ b/components/ui/slider.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import * as SliderPrimitive from "@radix-ui/react-slider"
+
+import { cn } from "@/lib/utils"
+
+const Slider = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+
+))
+Slider.displayName = SliderPrimitive.Root.displayName
+
+export { Slider }
diff --git a/contexts/ResumeContext.tsx b/contexts/ResumeContext.tsx
new file mode 100644
index 000000000..cadf35411
--- /dev/null
+++ b/contexts/ResumeContext.tsx
@@ -0,0 +1,872 @@
+'use client';
+
+import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
+import { createClient } from '@/lib/supabase/client';
+import {
+ Resume,
+ ResumeSection,
+ SectionType,
+ SectionContent,
+ ResumeStyling,
+ ResumeMetadata,
+ ResumeError,
+ ResumeErrorCode,
+ DEFAULT_STYLING,
+ DEFAULT_METADATA,
+ DEFAULT_PERSONAL_INFO,
+ SECTION_TYPES,
+ DeepPartial,
+ ResumeInsert,
+ ResumeUpdate,
+} from '@/types/resume';
+import { Profile } from '@/types/profile';
+import { ResumeImportService, ImportResult } from '@/lib/services/resume-import';
+import { updateResumeMetadata } from '@/lib/services/resume-metadata';
+import { ResumeScoringService, ScoringResult } from '@/lib/services/resume-scoring';
+import { saveToLocalStorage, loadFromLocalStorage, removeFromLocalStorage } from '@/lib/storage/offline-storage';
+
+// Context Type Definition
+export interface ResumeContextType {
+ // State
+ resume: Resume | null;
+ resumes: Resume[];
+ loading: boolean;
+ saving: boolean;
+ saveStatus: 'idle' | 'saving' | 'saved' | 'error';
+ error: string | null;
+ announcement: string;
+ lastAddedSectionId: string | null;
+
+ // Resume CRUD
+ createResume: (title: string, autoFill?: boolean) => Promise;
+ loadResume: (id: string) => Promise;
+ saveResume: () => Promise;
+ deleteResume: (id: string) => Promise;
+ duplicateResume: (id: string, newTitle?: string) => Promise;
+ updateResumeTitle: (title: string) => void;
+
+ // Section Management
+ addSection: (type: SectionType) => void;
+ removeSection: (sectionId: string) => void;
+ reorderSections: (sections: ResumeSection[]) => void;
+ updateSection: (sectionId: string, content: Partial) => void;
+ toggleSectionVisibility: (sectionId: string) => void;
+
+ // Styling
+ updateStyling: (styling: Partial) => void;
+ applyTemplate: (templateId: string) => void;
+
+ // Metadata
+ updateMetadata: (metadata: Partial) => void;
+
+ // Auto-fill
+ autoFillFromProfile: (profile: Profile) => Promise;
+
+ // Import
+ importFromJSON: (jsonString: string) => Promise;
+
+ // Utilities
+ calculateScore: () => number;
+ getDetailedScore: () => ScoringResult;
+ clearError: () => void;
+ refreshResumes: () => Promise;
+ setAutoSaveEnabled: (enabled: boolean) => void;
+}
+
+// Create Context
+const ResumeContext = createContext(undefined);
+
+// Provider Props
+interface ResumeProviderProps {
+ children: React.ReactNode;
+ initialResumes?: Resume[];
+ userProfile?: Profile | null;
+}
+
+// Helper function to generate unique IDs
+const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+
+// Helper function to create default section
+const createDefaultSection = (type: SectionType, order: number): ResumeSection => {
+ const sectionInfo = SECTION_TYPES[type];
+ let defaultContent: SectionContent;
+
+ switch (type) {
+ case 'personal_info':
+ defaultContent = DEFAULT_PERSONAL_INFO;
+ break;
+ case 'education':
+ case 'experience':
+ case 'projects':
+ case 'certifications':
+ case 'awards':
+ defaultContent = [];
+ break;
+ case 'skills':
+ defaultContent = [];
+ break;
+ case 'custom':
+ defaultContent = { title: '', content: '' };
+ break;
+ default:
+ defaultContent = [];
+ }
+
+ return {
+ id: generateId(),
+ type,
+ title: sectionInfo.defaultTitle,
+ order,
+ visible: true,
+ content: defaultContent,
+ };
+};
+
+// Provider Component
+export function ResumeProvider({ children, initialResumes = [], userProfile }: ResumeProviderProps) {
+ const [resume, setResume] = useState(null);
+ const [resumes, setResumes] = useState(initialResumes);
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
+ const [error, setError] = useState(null);
+ const [announcement, setAnnouncement] = useState('');
+ const [lastAddedSectionId, setLastAddedSectionId] = useState(null);
+
+ const supabase = createClient();
+ const saveTimeoutRef = useRef(null);
+ const autoSaveEnabledRef = useRef(true);
+ const lastSavedResumeRef = useRef('');
+ const saveQueueRef = useRef([]);
+ const isSavingRef = useRef(false);
+ const optimisticStateRef = useRef(null);
+
+ // Create a new resume
+ const createResume = useCallback(
+ async (title: string, autoFill = false): Promise => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) {
+ throw new ResumeError('User not authenticated', ResumeErrorCode.UNAUTHORIZED);
+ }
+
+ // Create default sections
+ const defaultSections: ResumeSection[] = [
+ createDefaultSection('personal_info', 0),
+ createDefaultSection('education', 1),
+ createDefaultSection('experience', 2),
+ createDefaultSection('skills', 3),
+ ];
+
+ const newResume: ResumeInsert = {
+ user_id: user.id,
+ title: title || 'Untitled Resume',
+ template_id: 'modern',
+ sections: defaultSections as unknown,
+ styling: DEFAULT_STYLING as unknown,
+ metadata: DEFAULT_METADATA as unknown,
+ };
+
+ const { data, error: insertError } = await supabase
+ .from('resumes')
+ .insert(newResume)
+ .select()
+ .single();
+
+ if (insertError) {
+ throw new ResumeError('Failed to create resume', ResumeErrorCode.SAVE_FAILED, insertError);
+ }
+
+ const createdResume: Resume = {
+ ...data,
+ sections: defaultSections,
+ styling: DEFAULT_STYLING,
+ metadata: DEFAULT_METADATA,
+ };
+
+ setResume(createdResume);
+ setResumes((prev) => [createdResume, ...prev]);
+
+ // Auto-fill if requested and profile is available
+ if (autoFill && userProfile) {
+ await autoFillFromProfile(userProfile);
+ }
+
+ return createdResume;
+ } catch (err) {
+ const errorMessage = err instanceof ResumeError ? err.message : 'Failed to create resume';
+ setError(errorMessage);
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [supabase, userProfile]
+ );
+
+ // Load a resume by ID
+ const loadResume = useCallback(
+ async (id: string): Promise => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const { data, error: fetchError } = await supabase
+ .from('resumes')
+ .select('*')
+ .eq('id', id)
+ .single();
+
+ if (fetchError) {
+ throw new ResumeError('Failed to load resume', ResumeErrorCode.LOAD_FAILED, fetchError);
+ }
+
+ if (!data) {
+ throw new ResumeError('Resume not found', ResumeErrorCode.NOT_FOUND);
+ }
+
+ const loadedResume: Resume = {
+ ...data,
+ sections: (data.sections as ResumeSection[]) || [],
+ styling: (data.styling as ResumeStyling) || DEFAULT_STYLING,
+ metadata: (data.metadata as ResumeMetadata) || DEFAULT_METADATA,
+ };
+
+ setResume(loadedResume);
+ } catch (err) {
+ const errorMessage = err instanceof ResumeError ? err.message : 'Failed to load resume';
+ setError(errorMessage);
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [supabase]
+ );
+
+ // Save the current resume
+ const saveResume = useCallback(async (): Promise => {
+ if (!resume) return;
+
+ try {
+ setSaving(true);
+ setSaveStatus('saving');
+ setError(null);
+
+ // Update metadata before saving
+ const updatedResume = updateResumeMetadata(resume);
+ setResume(updatedResume);
+
+ // Save to local storage first (offline fallback)
+ saveToLocalStorage(updatedResume);
+
+ const updateData: ResumeUpdate = {
+ title: updatedResume.title,
+ template_id: updatedResume.template_id,
+ sections: updatedResume.sections as unknown,
+ styling: updatedResume.styling as unknown,
+ metadata: updatedResume.metadata as unknown,
+ };
+
+ const { error: updateError } = await supabase
+ .from('resumes')
+ .update(updateData)
+ .eq('id', updatedResume.id);
+
+ if (updateError) {
+ throw new ResumeError('Failed to save resume', ResumeErrorCode.SAVE_FAILED, updateError);
+ }
+
+ // Remove from local storage after successful save
+ removeFromLocalStorage(updatedResume.id);
+
+ setSaveStatus('saved');
+ setAnnouncement('Resume saved successfully');
+
+ // Reset to idle after 2 seconds
+ setTimeout(() => {
+ setSaveStatus('idle');
+ }, 2000);
+
+ // Update resumes list
+ setResumes((prev) =>
+ prev.map((r) => (r.id === updatedResume.id ? { ...updatedResume, updated_at: new Date().toISOString() } : r))
+ );
+ } catch (err) {
+ const errorMessage = err instanceof ResumeError ? err.message : 'Failed to save resume';
+ setError(errorMessage);
+ setSaveStatus('error');
+
+ // Keep in local storage if save failed
+ if (resume) {
+ saveToLocalStorage(resume);
+ }
+
+ throw err;
+ } finally {
+ setSaving(false);
+ }
+ }, [resume, supabase]);
+
+ // Delete a resume
+ const deleteResume = useCallback(
+ async (id: string): Promise => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const { error: deleteError } = await supabase.from('resumes').delete().eq('id', id);
+
+ if (deleteError) {
+ throw new ResumeError('Failed to delete resume', ResumeErrorCode.SAVE_FAILED, deleteError);
+ }
+
+ setResumes((prev) => prev.filter((r) => r.id !== id));
+
+ if (resume?.id === id) {
+ setResume(null);
+ }
+ } catch (err) {
+ const errorMessage = err instanceof ResumeError ? err.message : 'Failed to delete resume';
+ setError(errorMessage);
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [resume, supabase]
+ );
+
+ // Duplicate a resume
+ const duplicateResume = useCallback(
+ async (id: string, newTitle?: string): Promise => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) {
+ throw new ResumeError('User not authenticated', ResumeErrorCode.UNAUTHORIZED);
+ }
+
+ // Find the resume to duplicate
+ const originalResume = resumes.find((r) => r.id === id);
+ if (!originalResume) {
+ throw new ResumeError('Resume not found', ResumeErrorCode.NOT_FOUND);
+ }
+
+ const duplicateData: ResumeInsert = {
+ user_id: user.id,
+ title: newTitle || `${originalResume.title} (Copy)`,
+ template_id: originalResume.template_id,
+ sections: originalResume.sections as unknown,
+ styling: originalResume.styling as unknown,
+ metadata: { ...originalResume.metadata, export_count: 0, last_exported: undefined } as unknown,
+ };
+
+ const { data, error: insertError } = await supabase
+ .from('resumes')
+ .insert(duplicateData)
+ .select()
+ .single();
+
+ if (insertError) {
+ throw new ResumeError('Failed to duplicate resume', ResumeErrorCode.SAVE_FAILED, insertError);
+ }
+
+ const duplicatedResume: Resume = {
+ ...data,
+ sections: originalResume.sections,
+ styling: originalResume.styling,
+ metadata: { ...originalResume.metadata, export_count: 0, last_exported: undefined },
+ };
+
+ setResumes((prev) => [duplicatedResume, ...prev]);
+
+ return duplicatedResume;
+ } catch (err) {
+ const errorMessage = err instanceof ResumeError ? err.message : 'Failed to duplicate resume';
+ setError(errorMessage);
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [resumes, supabase]
+ );
+
+ // Update resume title with optimistic update
+ const updateResumeTitle = useCallback((title: string) => {
+ setResume((prev) => {
+ if (!prev) return null;
+
+ // Store previous state for potential rollback
+ optimisticStateRef.current = prev;
+
+ // Apply optimistic update immediately
+ return { ...prev, title };
+ });
+ }, []);
+
+ // Add a section with optimistic update
+ const addSection = useCallback((type: SectionType) => {
+ setResume((prev) => {
+ if (!prev) return null;
+
+ // Store previous state for potential rollback
+ optimisticStateRef.current = prev;
+
+ const newSection = createDefaultSection(type, prev.sections.length);
+ const sectionInfo = SECTION_TYPES[type];
+ setAnnouncement(`${sectionInfo.defaultTitle} section added`);
+ setLastAddedSectionId(newSection.id);
+
+ // Clear the lastAddedSectionId after a short delay
+ setTimeout(() => setLastAddedSectionId(null), 500);
+
+ // Apply optimistic update immediately
+ return {
+ ...prev,
+ sections: [...prev.sections, newSection],
+ };
+ });
+ }, []);
+
+ // Remove a section with optimistic update
+ const removeSection = useCallback((sectionId: string) => {
+ setResume((prev) => {
+ if (!prev) return null;
+
+ // Store previous state for potential rollback
+ optimisticStateRef.current = prev;
+
+ const sectionToRemove = prev.sections.find((s) => s.id === sectionId);
+ if (sectionToRemove) {
+ setAnnouncement(`${sectionToRemove.title} section removed`);
+ }
+
+ const filteredSections = prev.sections.filter((s) => s.id !== sectionId);
+ // Reorder remaining sections
+ const reorderedSections = filteredSections.map((s, index) => ({ ...s, order: index }));
+
+ // Apply optimistic update immediately
+ return {
+ ...prev,
+ sections: reorderedSections,
+ };
+ });
+ }, []);
+
+ // Reorder sections with optimistic update
+ const reorderSections = useCallback((sections: ResumeSection[]) => {
+ setResume((prev) => {
+ if (!prev) return null;
+
+ // Store previous state for potential rollback
+ optimisticStateRef.current = prev;
+
+ // Update order property
+ const reorderedSections = sections.map((s, index) => ({ ...s, order: index }));
+ setAnnouncement('Section order updated');
+
+ // Apply optimistic update immediately
+ return {
+ ...prev,
+ sections: reorderedSections,
+ };
+ });
+ }, []);
+
+ // Update section content with optimistic update
+ const updateSection = useCallback((sectionId: string, content: Partial) => {
+ setResume((prev) => {
+ if (!prev) return null;
+
+ // Store previous state for potential rollback
+ optimisticStateRef.current = prev;
+
+ // Apply optimistic update immediately
+ return {
+ ...prev,
+ sections: prev.sections.map((s) =>
+ s.id === sectionId ? { ...s, content: { ...s.content, ...content } as SectionContent } : s
+ ),
+ };
+ });
+ }, []);
+
+ // Toggle section visibility
+ const toggleSectionVisibility = useCallback((sectionId: string) => {
+ setResume((prev) => {
+ if (!prev) return null;
+
+ const section = prev.sections.find((s) => s.id === sectionId);
+ if (section) {
+ setAnnouncement(`${section.title} section ${section.visible ? 'hidden' : 'shown'}`);
+ }
+
+ return {
+ ...prev,
+ sections: prev.sections.map((s) => (s.id === sectionId ? { ...s, visible: !s.visible } : s)),
+ };
+ });
+ }, []);
+
+ // Update styling with optimistic update
+ const updateStyling = useCallback((styling: Partial) => {
+ setResume((prev) => {
+ if (!prev) return null;
+
+ // Store previous state for potential rollback
+ optimisticStateRef.current = prev;
+
+ // Apply optimistic update immediately
+ return {
+ ...prev,
+ styling: { ...prev.styling, ...styling },
+ };
+ });
+ }, []);
+
+ // Apply template with optimistic update
+ const applyTemplate = useCallback((templateId: string) => {
+ setResume((prev) => {
+ if (!prev) return null;
+
+ // Store previous state for potential rollback
+ optimisticStateRef.current = prev;
+
+ // Apply optimistic update immediately
+ return {
+ ...prev,
+ template_id: templateId,
+ };
+ });
+ }, []);
+
+ // Update metadata
+ const updateMetadata = useCallback((metadata: Partial) => {
+ setResume((prev) => {
+ if (!prev) return null;
+
+ return {
+ ...prev,
+ metadata: { ...prev.metadata, ...metadata },
+ };
+ });
+ }, []);
+
+ // Auto-fill from profile
+ const autoFillFromProfile = useCallback(
+ async (profile: Profile): Promise => {
+ if (!resume) return;
+
+ try {
+ setError(null);
+
+ // Find personal info section
+ const personalInfoSection = resume.sections.find((s) => s.type === 'personal_info');
+ if (!personalInfoSection) return;
+
+ // Get user email from auth
+ const { data: { user } } = await supabase.auth.getUser();
+ const email = user?.email || '';
+
+ // Map profile data to personal info
+ const updatedPersonalInfo = {
+ full_name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim(),
+ email: email,
+ phone: profile.phone || '',
+ location: profile.location || '',
+ linkedin: profile.linkedin_url || '',
+ github: profile.github_url || '',
+ website: profile.twitter_url || '',
+ summary: profile.bio || '',
+ };
+
+ updateSection(personalInfoSection.id, updatedPersonalInfo);
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to auto-fill from profile';
+ setError(errorMessage);
+ }
+ },
+ [resume, updateSection, supabase]
+ );
+
+ // Import from JSON
+ const importFromJSON = useCallback(
+ async (jsonString: string): Promise => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) {
+ throw new ResumeError('User not authenticated', ResumeErrorCode.UNAUTHORIZED);
+ }
+
+ // Import and validate data
+ const result = await ResumeImportService.importFromJSON(jsonString, user.id);
+
+ if (!result.success || !result.resume) {
+ return result;
+ }
+
+ // Create new resume with imported data
+ const importedResume: ResumeInsert = {
+ user_id: user.id,
+ title: result.resume.title || 'Imported Resume',
+ template_id: result.resume.template_id || 'modern',
+ sections: result.resume.sections as unknown,
+ styling: result.resume.styling as unknown,
+ metadata: result.resume.metadata as unknown,
+ };
+
+ const { data, error: insertError } = await supabase
+ .from('resumes')
+ .insert(importedResume)
+ .select()
+ .single();
+
+ if (insertError) {
+ throw new ResumeError('Failed to save imported resume', ResumeErrorCode.SAVE_FAILED, insertError);
+ }
+
+ const createdResume: Resume = {
+ ...data,
+ sections: result.resume.sections || [],
+ styling: result.resume.styling || DEFAULT_STYLING,
+ metadata: result.resume.metadata || DEFAULT_METADATA,
+ };
+
+ setResume(createdResume);
+ setResumes((prev) => [createdResume, ...prev]);
+
+ return result;
+ } catch (err) {
+ const errorMessage = err instanceof ResumeError ? err.message : 'Failed to import resume';
+ setError(errorMessage);
+ return {
+ success: false,
+ errors: [errorMessage],
+ warnings: [],
+ fieldsPopulated: 0,
+ };
+ } finally {
+ setLoading(false);
+ }
+ },
+ [supabase]
+ );
+
+ // Calculate completeness score (simple version)
+ const calculateScore = useCallback((): number => {
+ return ResumeScoringService.calculateScore(resume).totalScore;
+ }, [resume]);
+
+ // Get detailed scoring result
+ const getDetailedScore = useCallback((): ScoringResult => {
+ return ResumeScoringService.calculateScore(resume);
+ }, [resume]);
+
+ // Clear error
+ const clearError = useCallback(() => {
+ setError(null);
+ setSaveStatus('idle');
+ }, []);
+
+ // Refresh resumes list
+ const refreshResumes = useCallback(async (): Promise => {
+ try {
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) return;
+
+ const { data, error: fetchError } = await supabase
+ .from('resumes')
+ .select('*')
+ .eq('user_id', user.id)
+ .order('updated_at', { ascending: false });
+
+ if (fetchError) {
+ throw new ResumeError('Failed to refresh resumes', ResumeErrorCode.LOAD_FAILED, fetchError);
+ }
+
+ const loadedResumes: Resume[] = (data || []).map((r) => ({
+ ...r,
+ sections: (r.sections as ResumeSection[]) || [],
+ styling: (r.styling as ResumeStyling) || DEFAULT_STYLING,
+ metadata: (r.metadata as ResumeMetadata) || DEFAULT_METADATA,
+ }));
+
+ setResumes(loadedResumes);
+ } catch (err) {
+ console.error('Failed to refresh resumes:', err);
+ }
+ }, [supabase]);
+
+ // Enable/disable auto-save
+ const setAutoSaveEnabled = useCallback((enabled: boolean) => {
+ autoSaveEnabledRef.current = enabled;
+ if (!enabled && saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ saveTimeoutRef.current = null;
+ }
+ }, []);
+
+ // Process save queue
+ const processSaveQueue = useCallback(async () => {
+ if (isSavingRef.current || saveQueueRef.current.length === 0) return;
+
+ isSavingRef.current = true;
+
+ // Get the latest resume from the queue (discard intermediate states)
+ const latestResume = saveQueueRef.current[saveQueueRef.current.length - 1];
+ saveQueueRef.current = [];
+
+ try {
+ setSaving(true);
+ setSaveStatus('saving');
+ setError(null);
+
+ // Update metadata before saving
+ const updatedResume = updateResumeMetadata(latestResume);
+
+ // Save to local storage first (offline fallback)
+ saveToLocalStorage(updatedResume);
+
+ const updateData: ResumeUpdate = {
+ title: updatedResume.title,
+ template_id: updatedResume.template_id,
+ sections: updatedResume.sections as unknown,
+ styling: updatedResume.styling as unknown,
+ metadata: updatedResume.metadata as unknown,
+ };
+
+ const { error: updateError } = await supabase
+ .from('resumes')
+ .update(updateData)
+ .eq('id', updatedResume.id);
+
+ if (updateError) {
+ throw new ResumeError('Failed to save resume', ResumeErrorCode.SAVE_FAILED, updateError);
+ }
+
+ // Remove from local storage after successful save
+ removeFromLocalStorage(updatedResume.id);
+
+ setSaveStatus('saved');
+ setAnnouncement('Resume saved successfully');
+ lastSavedResumeRef.current = JSON.stringify(updatedResume);
+
+ // Reset to idle after 2 seconds
+ setTimeout(() => {
+ setSaveStatus('idle');
+ }, 2000);
+
+ // Update resumes list
+ setResumes((prev) =>
+ prev.map((r) => (r.id === updatedResume.id ? { ...updatedResume, updated_at: new Date().toISOString() } : r))
+ );
+ } catch (err) {
+ const errorMessage = err instanceof ResumeError ? err.message : 'Failed to save resume';
+ setError(errorMessage);
+ setSaveStatus('error');
+
+ // Rollback to previous state if available
+ if (optimisticStateRef.current) {
+ setResume(optimisticStateRef.current);
+ setAnnouncement('Changes reverted due to save error');
+ }
+
+ // Keep in local storage if save failed
+ if (latestResume) {
+ saveToLocalStorage(latestResume);
+ }
+ } finally {
+ setSaving(false);
+ isSavingRef.current = false;
+
+ // Process any new items that were added to the queue while saving
+ if (saveQueueRef.current.length > 0) {
+ processSaveQueue();
+ }
+ }
+ }, [supabase]);
+
+ // Debounced auto-save effect with save queue
+ useEffect(() => {
+ if (!resume || !autoSaveEnabledRef.current) return;
+
+ // Create a snapshot of the current resume
+ const currentSnapshot = JSON.stringify(resume);
+
+ // Skip if nothing changed
+ if (currentSnapshot === lastSavedResumeRef.current) return;
+
+ // Add to save queue
+ saveQueueRef.current.push(resume);
+
+ // Clear existing timeout
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ }
+
+ // Set new timeout for auto-save (2 seconds)
+ saveTimeoutRef.current = setTimeout(() => {
+ processSaveQueue();
+ }, 2000);
+
+ // Cleanup on unmount
+ return () => {
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ }
+ };
+ }, [resume, processSaveQueue]);
+
+ const contextValue: ResumeContextType = {
+ resume,
+ resumes,
+ loading,
+ saving,
+ saveStatus,
+ error,
+ announcement,
+ lastAddedSectionId,
+ createResume,
+ loadResume,
+ saveResume,
+ deleteResume,
+ duplicateResume,
+ updateResumeTitle,
+ addSection,
+ removeSection,
+ reorderSections,
+ updateSection,
+ toggleSectionVisibility,
+ updateStyling,
+ applyTemplate,
+ updateMetadata,
+ autoFillFromProfile,
+ importFromJSON,
+ calculateScore,
+ getDetailedScore,
+ clearError,
+ refreshResumes,
+ setAutoSaveEnabled,
+ };
+
+ return {children} ;
+}
+
+// Custom hook to use the Resume Context
+export function useResume() {
+ const context = useContext(ResumeContext);
+ if (context === undefined) {
+ throw new Error('useResume must be used within a ResumeProvider');
+ }
+ return context;
+}
diff --git a/hooks/useFocusManagement.ts b/hooks/useFocusManagement.ts
new file mode 100644
index 000000000..86c8c50ff
--- /dev/null
+++ b/hooks/useFocusManagement.ts
@@ -0,0 +1,95 @@
+import { useEffect, useRef } from 'react';
+
+/**
+ * Hook to auto-focus the first input field in a section when it's added
+ */
+export function useAutoFocus(shouldFocus: boolean = true) {
+ const firstInputRef = useRef(null);
+
+ useEffect(() => {
+ if (shouldFocus && firstInputRef.current) {
+ // Small delay to ensure DOM is ready
+ const timer = setTimeout(() => {
+ firstInputRef.current?.focus();
+ }, 100);
+
+ return () => clearTimeout(timer);
+ }
+ }, [shouldFocus]);
+
+ return firstInputRef;
+}
+
+/**
+ * Hook to trap focus within a modal or dialog
+ */
+export function useFocusTrap(isActive: boolean = true) {
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ if (!isActive || !containerRef.current) return;
+
+ const container = containerRef.current;
+ const focusableElements = container.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ );
+
+ const firstElement = focusableElements[0];
+ const lastElement = focusableElements[focusableElements.length - 1];
+
+ // Focus first element on mount
+ firstElement?.focus();
+
+ const handleTabKey = (e: KeyboardEvent) => {
+ if (e.key !== 'Tab') return;
+
+ if (e.shiftKey) {
+ // Shift + Tab
+ if (document.activeElement === firstElement) {
+ e.preventDefault();
+ lastElement?.focus();
+ }
+ } else {
+ // Tab
+ if (document.activeElement === lastElement) {
+ e.preventDefault();
+ firstElement?.focus();
+ }
+ }
+ };
+
+ container.addEventListener('keydown', handleTabKey);
+
+ return () => {
+ container.removeEventListener('keydown', handleTabKey);
+ };
+ }, [isActive]);
+
+ return containerRef;
+}
+
+/**
+ * Hook to add visible focus indicators
+ */
+export function useFocusVisible() {
+ useEffect(() => {
+ // Add focus-visible class to body for keyboard navigation
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Tab') {
+ document.body.classList.add('keyboard-navigation');
+ }
+ };
+
+ const handleMouseDown = () => {
+ document.body.classList.remove('keyboard-navigation');
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ window.addEventListener('mousedown', handleMouseDown);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ window.removeEventListener('mousedown', handleMouseDown);
+ };
+ }, []);
+}
diff --git a/hooks/useKeyboardShortcuts.ts b/hooks/useKeyboardShortcuts.ts
new file mode 100644
index 000000000..4e360d4ca
--- /dev/null
+++ b/hooks/useKeyboardShortcuts.ts
@@ -0,0 +1,99 @@
+import { useEffect, useCallback, useState } from 'react';
+
+export interface KeyboardShortcut {
+ key: string;
+ ctrl?: boolean;
+ shift?: boolean;
+ alt?: boolean;
+ meta?: boolean;
+ handler: (event: KeyboardEvent) => void;
+ description: string;
+}
+
+export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[], enabled = true) {
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (!enabled) return;
+
+ for (const shortcut of shortcuts) {
+ const ctrlMatch = shortcut.ctrl ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey;
+ const shiftMatch = shortcut.shift ? event.shiftKey : !event.shiftKey;
+ const altMatch = shortcut.alt ? event.altKey : !event.altKey;
+ const metaMatch = shortcut.meta ? event.metaKey : !event.metaKey;
+
+ if (
+ event.key.toLowerCase() === shortcut.key.toLowerCase() &&
+ ctrlMatch &&
+ shiftMatch &&
+ altMatch &&
+ (shortcut.meta === undefined || metaMatch)
+ ) {
+ event.preventDefault();
+ shortcut.handler(event);
+ break;
+ }
+ }
+ },
+ [shortcuts, enabled]
+ );
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [handleKeyDown, enabled]);
+}
+
+export function useKeyboardNavigation(
+ items: string[],
+ onSelect: (index: number) => void,
+ enabled = true
+) {
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (!enabled || items.length === 0) return;
+
+ switch (event.key) {
+ case 'ArrowUp':
+ event.preventDefault();
+ setSelectedIndex((prev) => {
+ const newIndex = prev > 0 ? prev - 1 : items.length - 1;
+ return newIndex;
+ });
+ break;
+ case 'ArrowDown':
+ event.preventDefault();
+ setSelectedIndex((prev) => {
+ const newIndex = prev < items.length - 1 ? prev + 1 : 0;
+ return newIndex;
+ });
+ break;
+ case 'Enter':
+ event.preventDefault();
+ onSelect(selectedIndex);
+ break;
+ case 'Escape':
+ event.preventDefault();
+ // Let parent handle escape
+ break;
+ }
+ },
+ [enabled, items.length, selectedIndex, onSelect]
+ );
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [handleKeyDown, enabled]);
+
+ return { selectedIndex, setSelectedIndex };
+}
diff --git a/lib/errors/resume-errors.ts b/lib/errors/resume-errors.ts
new file mode 100644
index 000000000..4bfc7ce15
--- /dev/null
+++ b/lib/errors/resume-errors.ts
@@ -0,0 +1,157 @@
+/**
+ * Resume Builder Error Handling System
+ * Defines error types, codes, and error handling utilities
+ */
+
+export enum ResumeErrorCode {
+ LOAD_FAILED = 'LOAD_FAILED',
+ SAVE_FAILED = 'SAVE_FAILED',
+ EXPORT_FAILED = 'EXPORT_FAILED',
+ IMPORT_FAILED = 'IMPORT_FAILED',
+ VALIDATION_FAILED = 'VALIDATION_FAILED',
+ NETWORK_ERROR = 'NETWORK_ERROR',
+ UNAUTHORIZED = 'UNAUTHORIZED',
+ NOT_FOUND = 'NOT_FOUND',
+ RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
+ UNKNOWN_ERROR = 'UNKNOWN_ERROR',
+}
+
+export class ResumeError extends Error {
+ public readonly code: ResumeErrorCode;
+ public readonly details?: unknown;
+ public readonly timestamp: Date;
+ public readonly recoverable: boolean;
+
+ constructor(
+ message: string,
+ code: ResumeErrorCode,
+ details?: unknown,
+ recoverable = true
+ ) {
+ super(message);
+ this.name = 'ResumeError';
+ this.code = code;
+ this.details = details;
+ this.timestamp = new Date();
+ this.recoverable = recoverable;
+
+ // Maintains proper stack trace for where our error was thrown
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, ResumeError);
+ }
+ }
+
+ toJSON() {
+ return {
+ name: this.name,
+ message: this.message,
+ code: this.code,
+ details: this.details,
+ timestamp: this.timestamp.toISOString(),
+ recoverable: this.recoverable,
+ stack: this.stack,
+ };
+ }
+}
+
+/**
+ * Error logger utility
+ */
+export class ErrorLogger {
+ private static logs: Array<{
+ error: ResumeError;
+ context?: Record;
+ }> = [];
+
+ static log(error: ResumeError, context?: Record) {
+ this.logs.push({ error, context });
+
+ // Log to console in development
+ if (process.env.NODE_ENV === 'development') {
+ console.error('[ResumeError]', {
+ message: error.message,
+ code: error.code,
+ details: error.details,
+ context,
+ stack: error.stack,
+ });
+ }
+
+ // In production, you might want to send to an error tracking service
+ // e.g., Sentry, LogRocket, etc.
+ if (process.env.NODE_ENV === 'production') {
+ // Example: Sentry.captureException(error, { extra: context });
+ }
+ }
+
+ static getLogs() {
+ return this.logs;
+ }
+
+ static clearLogs() {
+ this.logs = [];
+ }
+}
+
+/**
+ * User-friendly error messages
+ */
+export const ERROR_MESSAGES: Record = {
+ [ResumeErrorCode.LOAD_FAILED]: 'Failed to load resume. Please try again.',
+ [ResumeErrorCode.SAVE_FAILED]: 'Failed to save resume. Your changes may not be saved.',
+ [ResumeErrorCode.EXPORT_FAILED]: 'Failed to export resume. Please try again.',
+ [ResumeErrorCode.IMPORT_FAILED]: 'Failed to import resume data. Please check the file format.',
+ [ResumeErrorCode.VALIDATION_FAILED]: 'Some fields contain invalid data. Please check and try again.',
+ [ResumeErrorCode.NETWORK_ERROR]: 'Network connection lost. Please check your internet connection.',
+ [ResumeErrorCode.UNAUTHORIZED]: 'You are not authorized to perform this action.',
+ [ResumeErrorCode.NOT_FOUND]: 'Resume not found.',
+ [ResumeErrorCode.RATE_LIMIT_EXCEEDED]: 'Too many requests. Please wait a moment and try again.',
+ [ResumeErrorCode.UNKNOWN_ERROR]: 'An unexpected error occurred. Please try again.',
+};
+
+/**
+ * Get user-friendly error message
+ */
+export function getErrorMessage(error: unknown): string {
+ if (error instanceof ResumeError) {
+ return ERROR_MESSAGES[error.code] || error.message;
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return ERROR_MESSAGES[ResumeErrorCode.UNKNOWN_ERROR];
+}
+
+/**
+ * Check if error is recoverable
+ */
+export function isRecoverableError(error: unknown): boolean {
+ if (error instanceof ResumeError) {
+ return error.recoverable;
+ }
+ return true; // Assume recoverable by default
+}
+
+/**
+ * Create error from unknown error
+ */
+export function createResumeError(
+ error: unknown,
+ defaultCode: ResumeErrorCode = ResumeErrorCode.UNKNOWN_ERROR
+): ResumeError {
+ if (error instanceof ResumeError) {
+ return error;
+ }
+
+ if (error instanceof Error) {
+ return new ResumeError(error.message, defaultCode, { originalError: error });
+ }
+
+ return new ResumeError(
+ 'An unexpected error occurred',
+ defaultCode,
+ { originalError: error }
+ );
+}
diff --git a/lib/services/resume-autofill.ts b/lib/services/resume-autofill.ts
new file mode 100644
index 000000000..eb72ac501
--- /dev/null
+++ b/lib/services/resume-autofill.ts
@@ -0,0 +1,108 @@
+import { createClient } from '@/lib/supabase/client';
+import { Profile } from '@/types/profile';
+import { PersonalInfo } from '@/types/resume';
+
+export class ResumeAutoFillService {
+ /**
+ * Fetch user profile and auth data for auto-filling resume
+ */
+ static async getUserDataForAutoFill(): Promise<{
+ profile: Profile | null;
+ email: string | null;
+ }> {
+ const supabase = createClient();
+
+ try {
+ // Get authenticated user
+ const { data: { user }, error: authError } = await supabase.auth.getUser();
+
+ if (authError || !user) {
+ console.error('Error fetching user:', authError);
+ return { profile: null, email: null };
+ }
+
+ // Get user profile
+ const { data: profile, error: profileError } = await supabase
+ .from('profiles')
+ .select('*')
+ .eq('id', user.id)
+ .single();
+
+ if (profileError) {
+ console.error('Error fetching profile:', profileError);
+ return { profile: null, email: user.email || null };
+ }
+
+ return {
+ profile: profile as Profile,
+ email: user.email || null,
+ };
+ } catch (error) {
+ console.error('Error in getUserDataForAutoFill:', error);
+ return { profile: null, email: null };
+ }
+ }
+
+ /**
+ * Map profile data to resume personal info section
+ */
+ static mapProfileToPersonalInfo(
+ profile: Profile | null,
+ email: string | null
+ ): Partial {
+ if (!profile && !email) {
+ return {};
+ }
+
+ const personalInfo: Partial = {};
+
+ if (profile) {
+ // Map name
+ if (profile.first_name || profile.last_name) {
+ personalInfo.full_name = `${profile.first_name || ''} ${profile.last_name || ''}`.trim();
+ }
+
+ // Map contact info
+ if (profile.phone) {
+ personalInfo.phone = profile.phone;
+ }
+
+ if (profile.location) {
+ personalInfo.location = profile.location;
+ }
+
+ // Map social links
+ if (profile.linkedin_url) {
+ personalInfo.linkedin = profile.linkedin_url;
+ }
+
+ if (profile.github_url) {
+ personalInfo.github = profile.github_url;
+ }
+
+ if (profile.twitter_url) {
+ personalInfo.website = profile.twitter_url;
+ }
+
+ // Map bio to summary
+ if (profile.bio) {
+ personalInfo.summary = profile.bio;
+ }
+ }
+
+ // Add email from auth
+ if (email) {
+ personalInfo.email = email;
+ }
+
+ return personalInfo;
+ }
+
+ /**
+ * Auto-fill resume from user profile
+ */
+ static async autoFillResume(): Promise> {
+ const { profile, email } = await this.getUserDataForAutoFill();
+ return this.mapProfileToPersonalInfo(profile, email);
+ }
+}
diff --git a/lib/services/resume-export-lazy.ts b/lib/services/resume-export-lazy.ts
new file mode 100644
index 000000000..3f5e40ad9
--- /dev/null
+++ b/lib/services/resume-export-lazy.ts
@@ -0,0 +1,144 @@
+/**
+ * Lazy-loaded Resume Export Service
+ * Dynamically imports heavy export libraries only when needed
+ */
+
+import { Resume, ExportFormat, ExportResult, ResumeError, ResumeErrorCode } from '@/types/resume';
+
+export class LazyResumeExportService {
+ /**
+ * Lazy load and export to PDF
+ */
+ static async exportToPDF(resume: Resume, elementId: string = 'resume-preview'): Promise {
+ try {
+ // Dynamically import the export service only when needed
+ const { ResumeExportService } = await import('./resume-export');
+ return await ResumeExportService.exportToPDF(resume, elementId);
+ } catch (error) {
+ console.error('Failed to load PDF export module:', error);
+ return {
+ success: false,
+ error: 'Failed to load PDF export functionality',
+ filename: '',
+ };
+ }
+ }
+
+ /**
+ * Lazy load and export to DOCX
+ */
+ static async exportToDOCX(resume: Resume): Promise {
+ try {
+ // Dynamically import the export service only when needed
+ const { ResumeExportService } = await import('./resume-export');
+ return await ResumeExportService.exportToDOCX(resume);
+ } catch (error) {
+ console.error('Failed to load DOCX export module:', error);
+ return {
+ success: false,
+ error: 'Failed to load DOCX export functionality',
+ filename: '',
+ };
+ }
+ }
+
+ /**
+ * Export to JSON (no lazy loading needed - lightweight)
+ */
+ static async exportToJSON(resume: Resume): Promise {
+ try {
+ // Dynamically import the export service
+ const { ResumeExportService } = await import('./resume-export');
+ return await ResumeExportService.exportToJSON(resume);
+ } catch (error) {
+ console.error('Failed to load JSON export module:', error);
+ return {
+ success: false,
+ error: 'Failed to load JSON export functionality',
+ filename: '',
+ };
+ }
+ }
+
+ /**
+ * Validate export format
+ */
+ static validateFormat(format: string): format is ExportFormat {
+ return ['pdf', 'docx', 'json'].includes(format);
+ }
+
+ /**
+ * Generate filename for export
+ */
+ static generateFilename(resume: Resume, format: ExportFormat): string {
+ const sanitizedTitle = resume.title
+ .replace(/[^a-z0-9]/gi, '_')
+ .toLowerCase()
+ .substring(0, 50);
+
+ const timestamp = new Date().toISOString().split('T')[0];
+ return `${sanitizedTitle}_${timestamp}.${format}`;
+ }
+
+ /**
+ * Trigger browser download
+ */
+ static triggerDownload(blob: Blob, filename: string): void {
+ try {
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ // Clean up the URL object after a short delay
+ setTimeout(() => URL.revokeObjectURL(url), 100);
+ } catch (error) {
+ throw new ResumeError(
+ 'Failed to trigger download',
+ ResumeErrorCode.EXPORT_FAILED,
+ error
+ );
+ }
+ }
+
+ /**
+ * Check if export format is supported
+ */
+ static isFormatSupported(format: string): boolean {
+ return this.validateFormat(format);
+ }
+
+ /**
+ * Get MIME type for export format
+ */
+ static getMimeType(format: ExportFormat): string {
+ const mimeTypes: Record = {
+ pdf: 'application/pdf',
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ json: 'application/json',
+ };
+
+ return mimeTypes[format];
+ }
+
+ /**
+ * Estimate export time (for progress indicators)
+ */
+ static estimateExportTime(resume: Resume, format: ExportFormat): number {
+ // Base time in milliseconds
+ const baseTimes: Record = {
+ pdf: 2000,
+ docx: 2500,
+ json: 100,
+ };
+
+ // Add time based on content size
+ const sectionCount = resume.sections.length;
+ const additionalTime = sectionCount * 100;
+
+ return baseTimes[format] + additionalTime;
+ }
+}
diff --git a/lib/services/resume-export.ts b/lib/services/resume-export.ts
new file mode 100644
index 000000000..99126f202
--- /dev/null
+++ b/lib/services/resume-export.ts
@@ -0,0 +1,583 @@
+/**
+ * Resume Export Service
+ * Handles exporting resumes to various formats (PDF, DOCX, JSON)
+ */
+
+import { Resume, ExportFormat, ExportResult, ResumeError, ResumeErrorCode, PersonalInfo, Education, Experience, Project, Skill, Certification, Award, CustomContent } from '@/types/resume';
+import jsPDF from 'jspdf';
+import html2canvas from 'html2canvas';
+import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType, convertInchesToTwip } from 'docx';
+
+export class ResumeExportService {
+ /**
+ * Validate export format
+ */
+ static validateFormat(format: string): format is ExportFormat {
+ return ['pdf', 'docx', 'json'].includes(format);
+ }
+
+ /**
+ * Generate filename for export
+ */
+ static generateFilename(resume: Resume, format: ExportFormat): string {
+ const sanitizedTitle = resume.title
+ .replace(/[^a-z0-9]/gi, '_')
+ .toLowerCase()
+ .substring(0, 50);
+
+ const timestamp = new Date().toISOString().split('T')[0];
+ return `${sanitizedTitle}_${timestamp}.${format}`;
+ }
+
+ /**
+ * Trigger browser download
+ */
+ static triggerDownload(blob: Blob, filename: string): void {
+ try {
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ // Clean up the URL object after a short delay
+ setTimeout(() => URL.revokeObjectURL(url), 100);
+ } catch (error) {
+ throw new ResumeError(
+ 'Failed to trigger download',
+ ResumeErrorCode.EXPORT_FAILED,
+ error
+ );
+ }
+ }
+
+ /**
+ * Handle export errors gracefully
+ */
+ static handleExportError(error: unknown, format: ExportFormat): ExportResult {
+ console.error(`Export to ${format.toUpperCase()} failed:`, error);
+
+ let errorMessage = `Failed to export resume as ${format.toUpperCase()}`;
+
+ if (error instanceof ResumeError) {
+ errorMessage = error.message;
+ } else if (error instanceof Error) {
+ errorMessage = error.message;
+ }
+
+ return {
+ success: false,
+ error: errorMessage,
+ filename: '',
+ };
+ }
+
+ /**
+ * Validate resume data before export
+ */
+ static validateResumeData(resume: Resume | null): void {
+ if (!resume) {
+ throw new ResumeError(
+ 'No resume data available for export',
+ ResumeErrorCode.VALIDATION_FAILED
+ );
+ }
+
+ if (!resume.sections || resume.sections.length === 0) {
+ throw new ResumeError(
+ 'Resume must have at least one section',
+ ResumeErrorCode.VALIDATION_FAILED
+ );
+ }
+ }
+
+ /**
+ * Check if export format is supported
+ */
+ static isFormatSupported(format: string): boolean {
+ return this.validateFormat(format);
+ }
+
+ /**
+ * Get MIME type for export format
+ */
+ static getMimeType(format: ExportFormat): string {
+ const mimeTypes: Record = {
+ pdf: 'application/pdf',
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ json: 'application/json',
+ };
+
+ return mimeTypes[format];
+ }
+
+ /**
+ * Estimate export time (for progress indicators)
+ */
+ static estimateExportTime(resume: Resume, format: ExportFormat): number {
+ // Base time in milliseconds
+ const baseTimes: Record = {
+ pdf: 2000,
+ docx: 2500,
+ json: 100,
+ };
+
+ // Add time based on content size
+ const sectionCount = resume.sections.length;
+ const additionalTime = sectionCount * 100;
+
+ return baseTimes[format] + additionalTime;
+ }
+
+ /**
+ * Export resume to PDF format
+ */
+ static async exportToPDF(resume: Resume, elementId: string = 'resume-preview'): Promise {
+ try {
+ this.validateResumeData(resume);
+
+ // Get the resume preview element
+ const element = document.getElementById(elementId);
+ if (!element) {
+ throw new ResumeError(
+ 'Resume preview element not found',
+ ResumeErrorCode.EXPORT_FAILED
+ );
+ }
+
+ // Capture the element as canvas with high quality
+ const canvas = await html2canvas(element, {
+ scale: 2, // Higher quality
+ useCORS: true,
+ logging: false,
+ backgroundColor: '#ffffff',
+ });
+
+ // Calculate dimensions for PDF (8.5" x 11" letter size)
+ const imgWidth = 210; // A4 width in mm
+ const imgHeight = (canvas.height * imgWidth) / canvas.width;
+
+ // Create PDF
+ const pdf = new jsPDF({
+ orientation: imgHeight > imgWidth ? 'portrait' : 'portrait',
+ unit: 'mm',
+ format: 'a4',
+ });
+
+ // Convert canvas to image
+ const imgData = canvas.toDataURL('image/png');
+
+ // Add image to PDF
+ pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight);
+
+ // Handle multi-page resumes
+ let heightLeft = imgHeight - 297; // A4 height in mm
+ let position = 0;
+
+ while (heightLeft > 0) {
+ position = heightLeft - imgHeight;
+ pdf.addPage();
+ pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
+ heightLeft -= 297;
+ }
+
+ // Generate blob
+ const blob = pdf.output('blob');
+
+ // Generate filename
+ const filename = this.generateFilename(resume, 'pdf');
+
+ return {
+ success: true,
+ blob,
+ filename,
+ };
+ } catch (error) {
+ return this.handleExportError(error, 'pdf');
+ }
+ }
+
+ /**
+ * Export resume to DOCX format
+ */
+ static async exportToDOCX(resume: Resume): Promise {
+ try {
+ this.validateResumeData(resume);
+
+ const sections: Paragraph[] = [];
+
+ // Process each section
+ for (const section of resume.sections.filter(s => s.visible)) {
+ switch (section.type) {
+ case 'personal_info':
+ sections.push(...this.createPersonalInfoSection(section.content as PersonalInfo));
+ break;
+ case 'education':
+ sections.push(...this.createEducationSection(section.content as Education[]));
+ break;
+ case 'experience':
+ sections.push(...this.createExperienceSection(section.content as Experience[]));
+ break;
+ case 'projects':
+ sections.push(...this.createProjectsSection(section.content as Project[]));
+ break;
+ case 'skills':
+ sections.push(...this.createSkillsSection(section.content as Skill[]));
+ break;
+ case 'certifications':
+ sections.push(...this.createCertificationsSection(section.content as Certification[]));
+ break;
+ case 'awards':
+ sections.push(...this.createAwardsSection(section.content as Award[]));
+ break;
+ case 'custom':
+ sections.push(...this.createCustomSection(section.content as CustomContent, section.title));
+ break;
+ }
+ }
+
+ // Create document
+ const doc = new Document({
+ sections: [{
+ properties: {
+ page: {
+ margin: {
+ top: convertInchesToTwip(resume.styling.margin_top),
+ bottom: convertInchesToTwip(resume.styling.margin_bottom),
+ left: convertInchesToTwip(resume.styling.margin_left),
+ right: convertInchesToTwip(resume.styling.margin_right),
+ },
+ },
+ },
+ children: sections,
+ }],
+ });
+
+ // Generate blob
+ const blob = await Packer.toBlob(doc);
+
+ // Generate filename
+ const filename = this.generateFilename(resume, 'docx');
+
+ return {
+ success: true,
+ blob,
+ filename,
+ };
+ } catch (error) {
+ return this.handleExportError(error, 'docx');
+ }
+ }
+
+ // Helper methods for DOCX sections
+ private static createPersonalInfoSection(content: PersonalInfo): Paragraph[] {
+ const paragraphs: Paragraph[] = [];
+
+ // Name
+ if (content.full_name) {
+ paragraphs.push(
+ new Paragraph({
+ text: content.full_name,
+ heading: HeadingLevel.HEADING_1,
+ alignment: AlignmentType.CENTER,
+ })
+ );
+ }
+
+ // Contact info
+ const contactInfo: string[] = [];
+ if (content.email) contactInfo.push(content.email);
+ if (content.phone) contactInfo.push(content.phone);
+ if (content.location) contactInfo.push(content.location);
+
+ if (contactInfo.length > 0) {
+ paragraphs.push(
+ new Paragraph({
+ text: contactInfo.join(' | '),
+ alignment: AlignmentType.CENTER,
+ })
+ );
+ }
+
+ // Links
+ const links: string[] = [];
+ if (content.website) links.push(content.website);
+ if (content.linkedin) links.push(content.linkedin);
+ if (content.github) links.push(content.github);
+
+ if (links.length > 0) {
+ paragraphs.push(
+ new Paragraph({
+ text: links.join(' | '),
+ alignment: AlignmentType.CENTER,
+ })
+ );
+ }
+
+ // Summary
+ if (content.summary) {
+ paragraphs.push(new Paragraph({ text: '' })); // Spacing
+ paragraphs.push(
+ new Paragraph({
+ text: content.summary,
+ })
+ );
+ }
+
+ paragraphs.push(new Paragraph({ text: '' })); // Spacing
+ return paragraphs;
+ }
+
+ private static createEducationSection(content: Education[]): Paragraph[] {
+ if (!content || content.length === 0) return [];
+
+ const paragraphs: Paragraph[] = [
+ new Paragraph({
+ text: 'Education',
+ heading: HeadingLevel.HEADING_2,
+ }),
+ ];
+
+ content.forEach((edu) => {
+ paragraphs.push(
+ new Paragraph({
+ children: [
+ new TextRun({ text: edu.institution, bold: true }),
+ new TextRun({ text: ` - ${edu.degree} in ${edu.field}` }),
+ ],
+ })
+ );
+
+ const dates = edu.current ? `${edu.start_date} - Present` : `${edu.start_date} - ${edu.end_date || ''}`;
+ paragraphs.push(new Paragraph({ text: dates }));
+
+ if (edu.gpa) {
+ paragraphs.push(new Paragraph({ text: `GPA: ${edu.gpa}` }));
+ }
+
+ if (edu.achievements && edu.achievements.length > 0) {
+ edu.achievements.forEach((achievement) => {
+ paragraphs.push(new Paragraph({ text: `• ${achievement}` }));
+ });
+ }
+
+ paragraphs.push(new Paragraph({ text: '' })); // Spacing
+ });
+
+ return paragraphs;
+ }
+
+ private static createExperienceSection(content: Experience[]): Paragraph[] {
+ if (!content || content.length === 0) return [];
+
+ const paragraphs: Paragraph[] = [
+ new Paragraph({
+ text: 'Work Experience',
+ heading: HeadingLevel.HEADING_2,
+ }),
+ ];
+
+ content.forEach((exp) => {
+ paragraphs.push(
+ new Paragraph({
+ children: [
+ new TextRun({ text: exp.position, bold: true }),
+ new TextRun({ text: ` - ${exp.company}` }),
+ ],
+ })
+ );
+
+ const dates = exp.current ? `${exp.start_date} - Present` : `${exp.start_date} - ${exp.end_date || ''}`;
+ paragraphs.push(new Paragraph({ text: `${dates} | ${exp.location}` }));
+
+ if (exp.description) {
+ paragraphs.push(new Paragraph({ text: exp.description }));
+ }
+
+ if (exp.achievements && exp.achievements.length > 0) {
+ exp.achievements.forEach((achievement) => {
+ paragraphs.push(new Paragraph({ text: `• ${achievement}` }));
+ });
+ }
+
+ paragraphs.push(new Paragraph({ text: '' })); // Spacing
+ });
+
+ return paragraphs;
+ }
+
+ private static createProjectsSection(content: Project[]): Paragraph[] {
+ if (!content || content.length === 0) return [];
+
+ const paragraphs: Paragraph[] = [
+ new Paragraph({
+ text: 'Projects',
+ heading: HeadingLevel.HEADING_2,
+ }),
+ ];
+
+ content.forEach((project) => {
+ paragraphs.push(
+ new Paragraph({
+ children: [
+ new TextRun({ text: project.name, bold: true }),
+ ],
+ })
+ );
+
+ if (project.description) {
+ paragraphs.push(new Paragraph({ text: project.description }));
+ }
+
+ if (project.technologies && project.technologies.length > 0) {
+ paragraphs.push(
+ new Paragraph({ text: `Technologies: ${project.technologies.join(', ')}` })
+ );
+ }
+
+ if (project.url || project.github) {
+ const links: string[] = [];
+ if (project.url) links.push(project.url);
+ if (project.github) links.push(project.github);
+ paragraphs.push(new Paragraph({ text: links.join(' | ') }));
+ }
+
+ paragraphs.push(new Paragraph({ text: '' })); // Spacing
+ });
+
+ return paragraphs;
+ }
+
+ private static createSkillsSection(content: Skill[]): Paragraph[] {
+ if (!content || content.length === 0) return [];
+
+ const paragraphs: Paragraph[] = [
+ new Paragraph({
+ text: 'Skills',
+ heading: HeadingLevel.HEADING_2,
+ }),
+ ];
+
+ content.forEach((skill) => {
+ paragraphs.push(
+ new Paragraph({
+ children: [
+ new TextRun({ text: `${skill.category}: `, bold: true }),
+ new TextRun({ text: skill.items.join(', ') }),
+ ],
+ })
+ );
+ });
+
+ paragraphs.push(new Paragraph({ text: '' })); // Spacing
+ return paragraphs;
+ }
+
+ private static createCertificationsSection(content: Certification[]): Paragraph[] {
+ if (!content || content.length === 0) return [];
+
+ const paragraphs: Paragraph[] = [
+ new Paragraph({
+ text: 'Certifications',
+ heading: HeadingLevel.HEADING_2,
+ }),
+ ];
+
+ content.forEach((cert) => {
+ paragraphs.push(
+ new Paragraph({
+ children: [
+ new TextRun({ text: cert.name, bold: true }),
+ new TextRun({ text: ` - ${cert.issuer}` }),
+ ],
+ })
+ );
+
+ paragraphs.push(new Paragraph({ text: cert.date }));
+
+ if (cert.credential_id) {
+ paragraphs.push(new Paragraph({ text: `Credential ID: ${cert.credential_id}` }));
+ }
+
+ paragraphs.push(new Paragraph({ text: '' })); // Spacing
+ });
+
+ return paragraphs;
+ }
+
+ private static createAwardsSection(content: Award[]): Paragraph[] {
+ if (!content || content.length === 0) return [];
+
+ const paragraphs: Paragraph[] = [
+ new Paragraph({
+ text: 'Awards & Honors',
+ heading: HeadingLevel.HEADING_2,
+ }),
+ ];
+
+ content.forEach((award) => {
+ paragraphs.push(
+ new Paragraph({
+ children: [
+ new TextRun({ text: award.title, bold: true }),
+ new TextRun({ text: ` - ${award.issuer}` }),
+ ],
+ })
+ );
+
+ paragraphs.push(new Paragraph({ text: award.date }));
+
+ if (award.description) {
+ paragraphs.push(new Paragraph({ text: award.description }));
+ }
+
+ paragraphs.push(new Paragraph({ text: '' })); // Spacing
+ });
+
+ return paragraphs;
+ }
+
+ private static createCustomSection(content: CustomContent, title: string): Paragraph[] {
+ const paragraphs: Paragraph[] = [
+ new Paragraph({
+ text: title,
+ heading: HeadingLevel.HEADING_2,
+ }),
+ ];
+
+ if (content.content) {
+ paragraphs.push(new Paragraph({ text: content.content }));
+ }
+
+ paragraphs.push(new Paragraph({ text: '' })); // Spacing
+ return paragraphs;
+ }
+
+ /**
+ * Export resume to JSON format
+ */
+ static async exportToJSON(resume: Resume): Promise {
+ try {
+ this.validateResumeData(resume);
+
+ // Serialize resume data to JSON with proper indentation
+ const jsonString = JSON.stringify(resume, null, 2);
+
+ // Create blob
+ const blob = new Blob([jsonString], { type: this.getMimeType('json') });
+
+ // Generate filename
+ const filename = this.generateFilename(resume, 'json');
+
+ return {
+ success: true,
+ blob,
+ filename,
+ };
+ } catch (error) {
+ return this.handleExportError(error, 'json');
+ }
+ }
+}
diff --git a/lib/services/resume-import.ts b/lib/services/resume-import.ts
new file mode 100644
index 000000000..ed5818c21
--- /dev/null
+++ b/lib/services/resume-import.ts
@@ -0,0 +1,400 @@
+import { Resume, ResumeSection, SectionType, SectionContent } from '@/types/resume';
+import { v4 as uuidv4 } from 'uuid';
+
+export interface ImportResult {
+ success: boolean;
+ resume?: Partial;
+ errors: string[];
+ warnings: string[];
+ fieldsPopulated: number;
+}
+
+export interface ImportValidationError {
+ field: string;
+ message: string;
+}
+
+export class ResumeImportService {
+ /**
+ * Import resume data from JSON string
+ */
+ static async importFromJSON(jsonString: string, userId: string): Promise {
+ const errors: string[] = [];
+ const warnings: string[] = [];
+ let fieldsPopulated = 0;
+
+ try {
+ // Parse JSON
+ const data = JSON.parse(jsonString);
+
+ // Validate basic structure
+ const validationErrors = this.validateStructure(data);
+ if (validationErrors.length > 0) {
+ return {
+ success: false,
+ errors: validationErrors.map(e => e.message),
+ warnings,
+ fieldsPopulated: 0,
+ };
+ }
+
+ // Map imported data to resume schema
+ const resume = this.mapToResumeSchema(data, userId);
+
+ // Count populated fields
+ fieldsPopulated = this.countPopulatedFields(resume);
+
+ // Check for unrecognized fields
+ const unrecognizedFields = this.findUnrecognizedFields(data);
+ if (unrecognizedFields.length > 0) {
+ warnings.push(
+ `Unrecognized fields found and ignored: ${unrecognizedFields.join(', ')}`
+ );
+ }
+
+ return {
+ success: true,
+ resume,
+ errors,
+ warnings,
+ fieldsPopulated,
+ };
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ errors.push('Invalid JSON format. Please check your file and try again.');
+ } else {
+ errors.push(`Import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+
+ return {
+ success: false,
+ errors,
+ warnings,
+ fieldsPopulated: 0,
+ };
+ }
+ }
+
+ /**
+ * Validate the structure of imported data
+ */
+ private static validateStructure(data: any): ImportValidationError[] {
+ const errors: ImportValidationError[] = [];
+
+ if (!data || typeof data !== 'object') {
+ errors.push({ field: 'root', message: 'Invalid data format. Expected an object.' });
+ return errors;
+ }
+
+ // Check for required fields
+ if (data.sections && !Array.isArray(data.sections)) {
+ errors.push({ field: 'sections', message: 'Sections must be an array.' });
+ }
+
+ // Validate sections if present
+ if (Array.isArray(data.sections)) {
+ data.sections.forEach((section: any, index: number) => {
+ if (!section.type) {
+ errors.push({
+ field: `sections[${index}]`,
+ message: 'Section must have a type.'
+ });
+ }
+ if (!section.content) {
+ errors.push({
+ field: `sections[${index}]`,
+ message: 'Section must have content.'
+ });
+ }
+ });
+ }
+
+ return errors;
+ }
+
+ /**
+ * Map imported data to resume schema
+ */
+ private static mapToResumeSchema(data: any, userId: string): Partial {
+ const now = new Date().toISOString();
+
+ const resume: Partial = {
+ id: data.id || uuidv4(),
+ user_id: userId,
+ title: data.title || 'Imported Resume',
+ template_id: data.template_id || 'modern',
+ sections: this.mapSections(data.sections || []),
+ styling: this.mapStyling(data.styling || {}),
+ metadata: this.mapMetadata(data.metadata || {}),
+ created_at: data.created_at || now,
+ updated_at: now,
+ };
+
+ return resume;
+ }
+
+ /**
+ * Map sections from imported data
+ */
+ private static mapSections(sections: any[]): ResumeSection[] {
+ return sections
+ .filter(section => this.isValidSectionType(section.type))
+ .map((section, index) => ({
+ id: section.id || uuidv4(),
+ type: section.type as SectionType,
+ title: section.title || this.getDefaultSectionTitle(section.type),
+ order: section.order ?? index,
+ visible: section.visible ?? true,
+ content: this.mapSectionContent(section.type, section.content),
+ }));
+ }
+
+ /**
+ * Map section content based on type
+ */
+ private static mapSectionContent(type: SectionType, content: any): SectionContent {
+ switch (type) {
+ case 'personal_info':
+ return {
+ full_name: content.full_name || '',
+ email: content.email || '',
+ phone: content.phone || '',
+ location: content.location || '',
+ website: content.website,
+ linkedin: content.linkedin,
+ github: content.github,
+ summary: content.summary,
+ };
+
+ case 'education':
+ return Array.isArray(content)
+ ? content.map(edu => ({
+ id: edu.id || uuidv4(),
+ institution: edu.institution || '',
+ degree: edu.degree || '',
+ field: edu.field || '',
+ start_date: edu.start_date || '',
+ end_date: edu.end_date,
+ current: edu.current || false,
+ gpa: edu.gpa,
+ achievements: Array.isArray(edu.achievements) ? edu.achievements : [],
+ }))
+ : [];
+
+ case 'experience':
+ return Array.isArray(content)
+ ? content.map(exp => ({
+ id: exp.id || uuidv4(),
+ company: exp.company || '',
+ position: exp.position || '',
+ location: exp.location || '',
+ start_date: exp.start_date || '',
+ end_date: exp.end_date,
+ current: exp.current || false,
+ description: exp.description || '',
+ achievements: Array.isArray(exp.achievements) ? exp.achievements : [],
+ }))
+ : [];
+
+ case 'projects':
+ return Array.isArray(content)
+ ? content.map(proj => ({
+ id: proj.id || uuidv4(),
+ name: proj.name || '',
+ description: proj.description || '',
+ technologies: Array.isArray(proj.technologies) ? proj.technologies : [],
+ url: proj.url,
+ github: proj.github,
+ start_date: proj.start_date,
+ end_date: proj.end_date,
+ }))
+ : [];
+
+ case 'skills':
+ return Array.isArray(content)
+ ? content.map(skill => ({
+ category: skill.category || 'General',
+ items: Array.isArray(skill.items) ? skill.items : [],
+ }))
+ : [];
+
+ case 'certifications':
+ return Array.isArray(content)
+ ? content.map(cert => ({
+ id: cert.id || uuidv4(),
+ name: cert.name || '',
+ issuer: cert.issuer || '',
+ date: cert.date || '',
+ expiry_date: cert.expiry_date,
+ credential_id: cert.credential_id,
+ url: cert.url,
+ }))
+ : [];
+
+ case 'awards':
+ return Array.isArray(content)
+ ? content.map(award => ({
+ id: award.id || uuidv4(),
+ title: award.title || '',
+ issuer: award.issuer || '',
+ date: award.date || '',
+ description: award.description,
+ }))
+ : [];
+
+ case 'custom':
+ return {
+ title: content.title || '',
+ content: content.content || '',
+ };
+
+ default:
+ return content;
+ }
+ }
+
+ /**
+ * Map styling from imported data
+ */
+ private static mapStyling(styling: any) {
+ return {
+ font_family: styling.font_family || 'Inter',
+ font_size_body: styling.font_size_body || 11,
+ font_size_heading: styling.font_size_heading || 16,
+ color_primary: styling.color_primary || '#8b5cf6',
+ color_text: styling.color_text || '#1f2937',
+ color_accent: styling.color_accent || '#6366f1',
+ margin_top: styling.margin_top || 0.5,
+ margin_bottom: styling.margin_bottom || 0.5,
+ margin_left: styling.margin_left || 0.75,
+ margin_right: styling.margin_right || 0.75,
+ line_height: styling.line_height || 1.5,
+ section_spacing: styling.section_spacing || 1.5,
+ };
+ }
+
+ /**
+ * Map metadata from imported data
+ */
+ private static mapMetadata(metadata: any) {
+ return {
+ page_count: metadata.page_count || 1,
+ word_count: metadata.word_count || 0,
+ completeness_score: metadata.completeness_score || 0,
+ last_exported: metadata.last_exported,
+ export_count: metadata.export_count || 0,
+ };
+ }
+
+ /**
+ * Check if section type is valid
+ */
+ private static isValidSectionType(type: string): boolean {
+ const validTypes: SectionType[] = [
+ 'personal_info',
+ 'education',
+ 'experience',
+ 'projects',
+ 'skills',
+ 'certifications',
+ 'awards',
+ 'custom',
+ ];
+ return validTypes.includes(type as SectionType);
+ }
+
+ /**
+ * Get default title for section type
+ */
+ private static getDefaultSectionTitle(type: SectionType): string {
+ const titles: Record = {
+ personal_info: 'Personal Information',
+ education: 'Education',
+ experience: 'Work Experience',
+ projects: 'Projects',
+ skills: 'Skills',
+ certifications: 'Certifications',
+ awards: 'Awards & Honors',
+ custom: 'Custom Section',
+ };
+ return titles[type] || 'Section';
+ }
+
+ /**
+ * Count populated fields in resume
+ */
+ private static countPopulatedFields(resume: Partial): number {
+ let count = 0;
+
+ // Count basic fields
+ if (resume.title) count++;
+ if (resume.template_id) count++;
+
+ // Count section fields
+ resume.sections?.forEach(section => {
+ count++; // Section itself
+ count += this.countSectionFields(section.content);
+ });
+
+ return count;
+ }
+
+ /**
+ * Count fields in section content
+ */
+ private static countSectionFields(content: SectionContent): number {
+ let count = 0;
+
+ if (Array.isArray(content)) {
+ content.forEach(item => {
+ Object.values(item).forEach(value => {
+ if (value && value !== '' && (!Array.isArray(value) || value.length > 0)) {
+ count++;
+ }
+ });
+ });
+ } else if (typeof content === 'object') {
+ Object.values(content).forEach(value => {
+ if (value && value !== '' && (!Array.isArray(value) || value.length > 0)) {
+ count++;
+ }
+ });
+ }
+
+ return count;
+ }
+
+ /**
+ * Find unrecognized fields in imported data
+ */
+ private static findUnrecognizedFields(data: any): string[] {
+ const recognizedFields = [
+ 'id',
+ 'user_id',
+ 'title',
+ 'template_id',
+ 'sections',
+ 'styling',
+ 'metadata',
+ 'created_at',
+ 'updated_at',
+ ];
+
+ return Object.keys(data).filter(key => !recognizedFields.includes(key));
+ }
+
+ /**
+ * Validate file size
+ */
+ static validateFileSize(file: File, maxSizeMB: number = 5): boolean {
+ const maxSizeBytes = maxSizeMB * 1024 * 1024;
+ return file.size <= maxSizeBytes;
+ }
+
+ /**
+ * Validate file type
+ */
+ static validateFileType(file: File): boolean {
+ return file.type === 'application/json' || file.name.endsWith('.json');
+ }
+}
diff --git a/lib/services/resume-metadata.ts b/lib/services/resume-metadata.ts
new file mode 100644
index 000000000..7d7ebf5b6
--- /dev/null
+++ b/lib/services/resume-metadata.ts
@@ -0,0 +1,180 @@
+import { Resume, PersonalInfo, Education, Experience, Project, Certification, Award, CustomContent } from '@/types/resume';
+
+/**
+ * Calculate the word count for a resume
+ */
+export function calculateWordCount(resume: Resume): number {
+ let wordCount = 0;
+
+ // Helper to count words in a string
+ const countWords = (text: string): number => {
+ if (!text || typeof text !== 'string') return 0;
+ return text.trim().split(/\s+/).filter(word => word.length > 0).length;
+ };
+
+ // Count words in each section
+ resume.sections.forEach((section) => {
+ if (!section.visible) return;
+
+ switch (section.type) {
+ case 'personal_info': {
+ const content = section.content as PersonalInfo;
+ wordCount += countWords(content.full_name || '');
+ wordCount += countWords(content.email || '');
+ wordCount += countWords(content.phone || '');
+ wordCount += countWords(content.location || '');
+ wordCount += countWords(content.website || '');
+ wordCount += countWords(content.linkedin || '');
+ wordCount += countWords(content.github || '');
+ wordCount += countWords(content.summary || '');
+ break;
+ }
+
+ case 'education': {
+ const content = section.content as Education[];
+ content.forEach((edu) => {
+ wordCount += countWords(edu.institution);
+ wordCount += countWords(edu.degree);
+ wordCount += countWords(edu.field);
+ wordCount += countWords(edu.gpa || '');
+ if (edu.achievements) {
+ edu.achievements.forEach((achievement) => {
+ wordCount += countWords(achievement);
+ });
+ }
+ });
+ break;
+ }
+
+ case 'experience': {
+ const content = section.content as Experience[];
+ content.forEach((exp) => {
+ wordCount += countWords(exp.company);
+ wordCount += countWords(exp.position);
+ wordCount += countWords(exp.location);
+ wordCount += countWords(exp.description);
+ exp.achievements.forEach((achievement) => {
+ wordCount += countWords(achievement);
+ });
+ });
+ break;
+ }
+
+ case 'projects': {
+ const content = section.content as Project[];
+ content.forEach((project) => {
+ wordCount += countWords(project.name);
+ wordCount += countWords(project.description);
+ project.technologies.forEach((tech) => {
+ wordCount += countWords(tech);
+ });
+ wordCount += countWords(project.url || '');
+ wordCount += countWords(project.github || '');
+ });
+ break;
+ }
+
+ case 'skills': {
+ const content = section.content as Array<{ category: string; items: string[] }>;
+ content.forEach((skillGroup) => {
+ wordCount += countWords(skillGroup.category);
+ skillGroup.items.forEach((item) => {
+ wordCount += countWords(item);
+ });
+ });
+ break;
+ }
+
+ case 'certifications': {
+ const content = section.content as Certification[];
+ content.forEach((cert) => {
+ wordCount += countWords(cert.name);
+ wordCount += countWords(cert.issuer);
+ wordCount += countWords(cert.credential_id || '');
+ wordCount += countWords(cert.url || '');
+ });
+ break;
+ }
+
+ case 'awards': {
+ const content = section.content as Award[];
+ content.forEach((award) => {
+ wordCount += countWords(award.title);
+ wordCount += countWords(award.issuer);
+ wordCount += countWords(award.description || '');
+ });
+ break;
+ }
+
+ case 'custom': {
+ const content = section.content as CustomContent;
+ wordCount += countWords(content.title);
+ wordCount += countWords(content.content);
+ break;
+ }
+ }
+ });
+
+ return wordCount;
+}
+
+/**
+ * Calculate the page count based on content length
+ * This is an approximation - actual page count depends on template and styling
+ */
+export function calculatePageCount(resume: Resume): number {
+ const wordCount = calculateWordCount(resume);
+
+ // Average words per page for a resume (considering formatting, spacing, etc.)
+ const WORDS_PER_PAGE = 400;
+
+ // Calculate pages, minimum 1
+ const pages = Math.max(1, Math.ceil(wordCount / WORDS_PER_PAGE));
+
+ return pages;
+}
+
+/**
+ * Update resume metadata with calculated values
+ */
+export function updateResumeMetadata(resume: Resume): Resume {
+ const wordCount = calculateWordCount(resume);
+ const pageCount = calculatePageCount(resume);
+
+ return {
+ ...resume,
+ metadata: {
+ ...resume.metadata,
+ word_count: wordCount,
+ page_count: pageCount,
+ },
+ };
+}
+
+/**
+ * Record an export event in metadata
+ */
+export function recordExport(resume: Resume): Resume {
+ return {
+ ...resume,
+ metadata: {
+ ...resume.metadata,
+ last_exported: new Date().toISOString(),
+ export_count: (resume.metadata.export_count || 0) + 1,
+ },
+ };
+}
+
+/**
+ * Format metadata for display
+ */
+export function formatMetadata(resume: Resume) {
+ const { word_count, page_count, last_exported, export_count } = resume.metadata;
+
+ return {
+ wordCount: word_count || 0,
+ pageCount: page_count || 1,
+ lastExported: last_exported ? new Date(last_exported) : null,
+ exportCount: export_count || 0,
+ };
+}
diff --git a/lib/services/resume-scoring.ts b/lib/services/resume-scoring.ts
new file mode 100644
index 000000000..543ed6be8
--- /dev/null
+++ b/lib/services/resume-scoring.ts
@@ -0,0 +1,844 @@
+// Resume Scoring Service
+// Calculates completeness score and provides improvement suggestions
+
+import {
+ Resume,
+ ResumeSection,
+ PersonalInfo,
+ Education,
+ Experience,
+ Project,
+ Skill,
+ Certification,
+ Award,
+ CustomContent,
+} from '@/types/resume';
+
+export interface ScoreBreakdown {
+ category: string;
+ score: number;
+ maxScore: number;
+ percentage: number;
+ issues: string[];
+}
+
+export interface ResumeSuggestion {
+ id: string;
+ category: string;
+ severity: 'critical' | 'warning' | 'info';
+ message: string;
+ sectionId?: string;
+}
+
+export interface ScoringResult {
+ totalScore: number;
+ breakdown: ScoreBreakdown[];
+ suggestions: ResumeSuggestion[];
+ completedCategories: number;
+ totalCategories: number;
+}
+
+export class ResumeScoringService {
+ private static readonly WEIGHTS = {
+ personal_info: 20,
+ education: 15,
+ experience: 25,
+ projects: 15,
+ skills: 15,
+ certifications: 5,
+ awards: 5,
+ };
+
+ private static readonly MIN_CONTENT_LENGTH = 50;
+ private static readonly MIN_SUMMARY_LENGTH = 100;
+ private static readonly MIN_DESCRIPTION_LENGTH = 50;
+
+ /**
+ * Calculate comprehensive resume score with breakdown
+ */
+ static calculateScore(resume: Resume | null): ScoringResult {
+ if (!resume) {
+ return {
+ totalScore: 0,
+ breakdown: [],
+ suggestions: [],
+ completedCategories: 0,
+ totalCategories: 0,
+ };
+ }
+
+ const breakdown: ScoreBreakdown[] = [];
+ const suggestions: ResumeSuggestion[] = [];
+ let totalScore = 0;
+ let completedCategories = 0;
+
+ // Score each section type
+ const sectionsByType = this.groupSectionsByType(resume.sections);
+
+ // Personal Info
+ const personalInfoResult = this.scorePersonalInfo(sectionsByType.personal_info);
+ breakdown.push(personalInfoResult.breakdown);
+ suggestions.push(...personalInfoResult.suggestions);
+ totalScore += personalInfoResult.breakdown.score;
+ if (personalInfoResult.breakdown.percentage >= 80) completedCategories++;
+
+ // Education
+ const educationResult = this.scoreEducation(sectionsByType.education);
+ breakdown.push(educationResult.breakdown);
+ suggestions.push(...educationResult.suggestions);
+ totalScore += educationResult.breakdown.score;
+ if (educationResult.breakdown.percentage >= 80) completedCategories++;
+
+ // Experience
+ const experienceResult = this.scoreExperience(sectionsByType.experience);
+ breakdown.push(experienceResult.breakdown);
+ suggestions.push(...experienceResult.suggestions);
+ totalScore += experienceResult.breakdown.score;
+ if (experienceResult.breakdown.percentage >= 80) completedCategories++;
+
+ // Projects
+ const projectsResult = this.scoreProjects(sectionsByType.projects);
+ breakdown.push(projectsResult.breakdown);
+ suggestions.push(...projectsResult.suggestions);
+ totalScore += projectsResult.breakdown.score;
+ if (projectsResult.breakdown.percentage >= 80) completedCategories++;
+
+ // Skills
+ const skillsResult = this.scoreSkills(sectionsByType.skills);
+ breakdown.push(skillsResult.breakdown);
+ suggestions.push(...skillsResult.suggestions);
+ totalScore += skillsResult.breakdown.score;
+ if (skillsResult.breakdown.percentage >= 80) completedCategories++;
+
+ // Certifications
+ const certificationsResult = this.scoreCertifications(sectionsByType.certifications);
+ breakdown.push(certificationsResult.breakdown);
+ suggestions.push(...certificationsResult.suggestions);
+ totalScore += certificationsResult.breakdown.score;
+ if (certificationsResult.breakdown.percentage >= 80) completedCategories++;
+
+ // Awards
+ const awardsResult = this.scoreAwards(sectionsByType.awards);
+ breakdown.push(awardsResult.breakdown);
+ suggestions.push(...awardsResult.suggestions);
+ totalScore += awardsResult.breakdown.score;
+ if (awardsResult.breakdown.percentage >= 80) completedCategories++;
+
+ // Add general best practices suggestions
+ suggestions.push(...this.checkBestPractices(resume));
+
+ return {
+ totalScore: Math.round(totalScore),
+ breakdown,
+ suggestions: this.prioritizeSuggestions(suggestions),
+ completedCategories,
+ totalCategories: 7,
+ };
+ }
+
+ /**
+ * Group sections by type
+ */
+ private static groupSectionsByType(sections: ResumeSection[]): Record {
+ const grouped: Record = {};
+ sections.forEach((section) => {
+ if (!grouped[section.type]) {
+ grouped[section.type] = [];
+ }
+ grouped[section.type].push(section);
+ });
+ return grouped;
+ }
+
+ /**
+ * Score Personal Info section
+ */
+ private static scorePersonalInfo(sections: ResumeSection[] = []): {
+ breakdown: ScoreBreakdown;
+ suggestions: ResumeSuggestion[];
+ } {
+ const maxScore = this.WEIGHTS.personal_info;
+ const issues: string[] = [];
+ const suggestions: ResumeSuggestion[] = [];
+
+ if (sections.length === 0 || !sections[0].visible) {
+ return {
+ breakdown: {
+ category: 'Personal Information',
+ score: 0,
+ maxScore,
+ percentage: 0,
+ issues: ['Section is missing or hidden'],
+ },
+ suggestions: [
+ {
+ id: 'personal-info-missing',
+ category: 'Personal Information',
+ severity: 'critical',
+ message: 'Add your personal information including name, email, and contact details',
+ },
+ ],
+ };
+ }
+
+ const section = sections[0];
+ const content = section.content as PersonalInfo;
+ const requiredFields = ['full_name', 'email', 'phone'];
+ const optionalFields = ['location', 'linkedin', 'github', 'website', 'summary'];
+
+ let filledRequired = 0;
+ let filledOptional = 0;
+
+ // Check required fields
+ requiredFields.forEach((field) => {
+ const value = content[field as keyof PersonalInfo];
+ if (value && String(value).trim().length > 0) {
+ filledRequired++;
+ } else {
+ issues.push(`Missing ${field.replace('_', ' ')}`);
+ suggestions.push({
+ id: `personal-info-${field}`,
+ category: 'Personal Information',
+ severity: 'critical',
+ message: `Add your ${field.replace('_', ' ')}`,
+ sectionId: section.id,
+ });
+ }
+ });
+
+ // Check optional fields
+ optionalFields.forEach((field) => {
+ const value = content[field as keyof PersonalInfo];
+ if (value && String(value).trim().length > 0) {
+ filledOptional++;
+ }
+ });
+
+ // Check summary length
+ if (content.summary && content.summary.length < this.MIN_SUMMARY_LENGTH) {
+ issues.push('Summary is too short');
+ suggestions.push({
+ id: 'personal-info-summary-short',
+ category: 'Personal Information',
+ severity: 'warning',
+ message: `Expand your summary to at least ${this.MIN_SUMMARY_LENGTH} characters for better impact`,
+ sectionId: section.id,
+ });
+ } else if (!content.summary || content.summary.trim().length === 0) {
+ suggestions.push({
+ id: 'personal-info-summary-missing',
+ category: 'Personal Information',
+ severity: 'warning',
+ message: 'Add a professional summary to introduce yourself',
+ sectionId: section.id,
+ });
+ }
+
+ // Calculate score: 60% for required fields, 40% for optional fields
+ const requiredScore = (filledRequired / requiredFields.length) * 0.6;
+ const optionalScore = (filledOptional / optionalFields.length) * 0.4;
+ const score = (requiredScore + optionalScore) * maxScore;
+
+ return {
+ breakdown: {
+ category: 'Personal Information',
+ score,
+ maxScore,
+ percentage: Math.round((score / maxScore) * 100),
+ issues,
+ },
+ suggestions,
+ };
+ }
+
+ /**
+ * Score Education section
+ */
+ private static scoreEducation(sections: ResumeSection[] = []): {
+ breakdown: ScoreBreakdown;
+ suggestions: ResumeSuggestion[];
+ } {
+ const maxScore = this.WEIGHTS.education;
+ const issues: string[] = [];
+ const suggestions: ResumeSuggestion[] = [];
+
+ const visibleSections = sections.filter((s) => s.visible);
+
+ if (visibleSections.length === 0) {
+ return {
+ breakdown: {
+ category: 'Education',
+ score: 0,
+ maxScore,
+ percentage: 0,
+ issues: ['No education entries'],
+ },
+ suggestions: [
+ {
+ id: 'education-missing',
+ category: 'Education',
+ severity: 'critical',
+ message: 'Add at least one education entry',
+ },
+ ],
+ };
+ }
+
+ let totalEntries = 0;
+ let completeEntries = 0;
+
+ visibleSections.forEach((section) => {
+ const entries = section.content as Education[];
+ totalEntries += entries.length;
+
+ entries.forEach((entry, index) => {
+ const requiredFields = ['institution', 'degree', 'field', 'start_date'];
+ const missingFields = requiredFields.filter(
+ (field) => !entry[field as keyof Education] || String(entry[field as keyof Education]).trim().length === 0
+ );
+
+ if (missingFields.length === 0) {
+ completeEntries++;
+ } else {
+ issues.push(`Entry ${index + 1}: Missing ${missingFields.join(', ')}`);
+ suggestions.push({
+ id: `education-incomplete-${section.id}-${index}`,
+ category: 'Education',
+ severity: 'warning',
+ message: `Complete education entry ${index + 1}: Add ${missingFields.join(', ')}`,
+ sectionId: section.id,
+ });
+ }
+ });
+ });
+
+ if (totalEntries === 0) {
+ issues.push('No education entries added');
+ suggestions.push({
+ id: 'education-empty',
+ category: 'Education',
+ severity: 'critical',
+ message: 'Add your educational background',
+ });
+ }
+
+ const score = totalEntries > 0 ? (completeEntries / totalEntries) * maxScore : 0;
+
+ return {
+ breakdown: {
+ category: 'Education',
+ score,
+ maxScore,
+ percentage: Math.round((score / maxScore) * 100),
+ issues,
+ },
+ suggestions,
+ };
+ }
+
+ /**
+ * Score Experience section
+ */
+ private static scoreExperience(sections: ResumeSection[] = []): {
+ breakdown: ScoreBreakdown;
+ suggestions: ResumeSuggestion[];
+ } {
+ const maxScore = this.WEIGHTS.experience;
+ const issues: string[] = [];
+ const suggestions: ResumeSuggestion[] = [];
+
+ const visibleSections = sections.filter((s) => s.visible);
+
+ if (visibleSections.length === 0) {
+ return {
+ breakdown: {
+ category: 'Work Experience',
+ score: 0,
+ maxScore,
+ percentage: 0,
+ issues: ['No experience entries'],
+ },
+ suggestions: [
+ {
+ id: 'experience-missing',
+ category: 'Work Experience',
+ severity: 'critical',
+ message: 'Add your work experience to showcase your professional background',
+ },
+ ],
+ };
+ }
+
+ let totalEntries = 0;
+ let completeEntries = 0;
+
+ visibleSections.forEach((section) => {
+ const entries = section.content as Experience[];
+ totalEntries += entries.length;
+
+ entries.forEach((entry, index) => {
+ let entryScore = 0;
+ const entryIssues: string[] = [];
+
+ // Check required fields
+ const requiredFields = ['company', 'position', 'start_date', 'description'];
+ const missingFields = requiredFields.filter(
+ (field) => !entry[field as keyof Experience] || String(entry[field as keyof Experience]).trim().length === 0
+ );
+
+ if (missingFields.length > 0) {
+ entryIssues.push(`Missing ${missingFields.join(', ')}`);
+ } else {
+ entryScore += 0.5;
+ }
+
+ // Check description length
+ if (entry.description && entry.description.length >= this.MIN_DESCRIPTION_LENGTH) {
+ entryScore += 0.25;
+ } else {
+ entryIssues.push('Description too short');
+ suggestions.push({
+ id: `experience-description-short-${section.id}-${index}`,
+ category: 'Work Experience',
+ severity: 'warning',
+ message: `Expand description for ${entry.position || 'position'} (at least ${this.MIN_DESCRIPTION_LENGTH} characters)`,
+ sectionId: section.id,
+ });
+ }
+
+ // Check achievements
+ if (entry.achievements && entry.achievements.length > 0) {
+ entryScore += 0.25;
+ } else {
+ entryIssues.push('No achievements listed');
+ suggestions.push({
+ id: `experience-achievements-missing-${section.id}-${index}`,
+ category: 'Work Experience',
+ severity: 'info',
+ message: `Add achievements for ${entry.position || 'position'} to highlight your impact`,
+ sectionId: section.id,
+ });
+ }
+
+ if (entryScore >= 0.8) {
+ completeEntries++;
+ }
+
+ if (entryIssues.length > 0) {
+ issues.push(`Entry ${index + 1}: ${entryIssues.join(', ')}`);
+ }
+ });
+ });
+
+ if (totalEntries === 0) {
+ issues.push('No experience entries added');
+ suggestions.push({
+ id: 'experience-empty',
+ category: 'Work Experience',
+ severity: 'critical',
+ message: 'Add your work experience',
+ });
+ }
+
+ const score = totalEntries > 0 ? (completeEntries / totalEntries) * maxScore : 0;
+
+ return {
+ breakdown: {
+ category: 'Work Experience',
+ score,
+ maxScore,
+ percentage: Math.round((score / maxScore) * 100),
+ issues,
+ },
+ suggestions,
+ };
+ }
+
+ /**
+ * Score Projects section
+ */
+ private static scoreProjects(sections: ResumeSection[] = []): {
+ breakdown: ScoreBreakdown;
+ suggestions: ResumeSuggestion[];
+ } {
+ const maxScore = this.WEIGHTS.projects;
+ const issues: string[] = [];
+ const suggestions: ResumeSuggestion[] = [];
+
+ const visibleSections = sections.filter((s) => s.visible);
+
+ if (visibleSections.length === 0) {
+ return {
+ breakdown: {
+ category: 'Projects',
+ score: maxScore * 0.5, // 50% for not having projects (optional)
+ maxScore,
+ percentage: 50,
+ issues: ['No projects section'],
+ },
+ suggestions: [
+ {
+ id: 'projects-missing',
+ category: 'Projects',
+ severity: 'info',
+ message: 'Consider adding projects to showcase your practical skills',
+ },
+ ],
+ };
+ }
+
+ let totalEntries = 0;
+ let completeEntries = 0;
+
+ visibleSections.forEach((section) => {
+ const entries = section.content as Project[];
+ totalEntries += entries.length;
+
+ entries.forEach((entry, index) => {
+ const requiredFields = ['name', 'description', 'technologies'];
+ const missingFields: string[] = [];
+
+ if (!entry.name || entry.name.trim().length === 0) missingFields.push('name');
+ if (!entry.description || entry.description.trim().length === 0) missingFields.push('description');
+ if (!entry.technologies || entry.technologies.length === 0) missingFields.push('technologies');
+
+ if (missingFields.length === 0 && entry.description.length >= this.MIN_CONTENT_LENGTH) {
+ completeEntries++;
+ } else {
+ if (missingFields.length > 0) {
+ issues.push(`Entry ${index + 1}: Missing ${missingFields.join(', ')}`);
+ }
+ if (entry.description && entry.description.length < this.MIN_CONTENT_LENGTH) {
+ issues.push(`Entry ${index + 1}: Description too short`);
+ }
+ }
+ });
+ });
+
+ if (totalEntries === 0) {
+ issues.push('No projects added');
+ suggestions.push({
+ id: 'projects-empty',
+ category: 'Projects',
+ severity: 'info',
+ message: 'Add projects to demonstrate your skills',
+ });
+ return {
+ breakdown: {
+ category: 'Projects',
+ score: maxScore * 0.5,
+ maxScore,
+ percentage: 50,
+ issues,
+ },
+ suggestions,
+ };
+ }
+
+ const score = (completeEntries / totalEntries) * maxScore;
+
+ return {
+ breakdown: {
+ category: 'Projects',
+ score,
+ maxScore,
+ percentage: Math.round((score / maxScore) * 100),
+ issues,
+ },
+ suggestions,
+ };
+ }
+
+ /**
+ * Score Skills section
+ */
+ private static scoreSkills(sections: ResumeSection[] = []): {
+ breakdown: ScoreBreakdown;
+ suggestions: ResumeSuggestion[];
+ } {
+ const maxScore = this.WEIGHTS.skills;
+ const issues: string[] = [];
+ const suggestions: ResumeSuggestion[] = [];
+
+ const visibleSections = sections.filter((s) => s.visible);
+
+ if (visibleSections.length === 0) {
+ return {
+ breakdown: {
+ category: 'Skills',
+ score: 0,
+ maxScore,
+ percentage: 0,
+ issues: ['No skills section'],
+ },
+ suggestions: [
+ {
+ id: 'skills-missing',
+ category: 'Skills',
+ severity: 'critical',
+ message: 'Add your skills to highlight your capabilities',
+ },
+ ],
+ };
+ }
+
+ let totalSkills = 0;
+ let totalCategories = 0;
+
+ visibleSections.forEach((section) => {
+ const skills = section.content as Skill[];
+ totalCategories += skills.length;
+
+ skills.forEach((skill) => {
+ if (skill.items && skill.items.length > 0) {
+ totalSkills += skill.items.length;
+ }
+ });
+ });
+
+ if (totalSkills === 0) {
+ issues.push('No skills added');
+ suggestions.push({
+ id: 'skills-empty',
+ category: 'Skills',
+ severity: 'critical',
+ message: 'Add your technical and professional skills',
+ });
+ } else if (totalSkills < 5) {
+ issues.push('Too few skills listed');
+ suggestions.push({
+ id: 'skills-few',
+ category: 'Skills',
+ severity: 'warning',
+ message: 'Add more skills to better showcase your capabilities (aim for at least 5-10)',
+ });
+ }
+
+ // Score based on number of skills (5+ skills = full score)
+ const score = Math.min(totalSkills / 5, 1) * maxScore;
+
+ return {
+ breakdown: {
+ category: 'Skills',
+ score,
+ maxScore,
+ percentage: Math.round((score / maxScore) * 100),
+ issues,
+ },
+ suggestions,
+ };
+ }
+
+ /**
+ * Score Certifications section
+ */
+ private static scoreCertifications(sections: ResumeSection[] = []): {
+ breakdown: ScoreBreakdown;
+ suggestions: ResumeSuggestion[];
+ } {
+ const maxScore = this.WEIGHTS.certifications;
+ const issues: string[] = [];
+ const suggestions: ResumeSuggestion[] = [];
+
+ const visibleSections = sections.filter((s) => s.visible);
+
+ if (visibleSections.length === 0) {
+ return {
+ breakdown: {
+ category: 'Certifications',
+ score: maxScore, // Full score if not present (optional)
+ maxScore,
+ percentage: 100,
+ issues: [],
+ },
+ suggestions: [],
+ };
+ }
+
+ let totalEntries = 0;
+
+ visibleSections.forEach((section) => {
+ const entries = section.content as Certification[];
+ totalEntries += entries.length;
+ });
+
+ if (totalEntries === 0) {
+ return {
+ breakdown: {
+ category: 'Certifications',
+ score: maxScore,
+ maxScore,
+ percentage: 100,
+ issues: [],
+ },
+ suggestions: [],
+ };
+ }
+
+ // If certifications are present, give full score
+ return {
+ breakdown: {
+ category: 'Certifications',
+ score: maxScore,
+ maxScore,
+ percentage: 100,
+ issues: [],
+ },
+ suggestions: [],
+ };
+ }
+
+ /**
+ * Score Awards section
+ */
+ private static scoreAwards(sections: ResumeSection[] = []): {
+ breakdown: ScoreBreakdown;
+ suggestions: ResumeSuggestion[];
+ } {
+ const maxScore = this.WEIGHTS.awards;
+ const issues: string[] = [];
+ const suggestions: ResumeSuggestion[] = [];
+
+ const visibleSections = sections.filter((s) => s.visible);
+
+ if (visibleSections.length === 0) {
+ return {
+ breakdown: {
+ category: 'Awards',
+ score: maxScore, // Full score if not present (optional)
+ maxScore,
+ percentage: 100,
+ issues: [],
+ },
+ suggestions: [],
+ };
+ }
+
+ let totalEntries = 0;
+
+ visibleSections.forEach((section) => {
+ const entries = section.content as Award[];
+ totalEntries += entries.length;
+ });
+
+ if (totalEntries === 0) {
+ return {
+ breakdown: {
+ category: 'Awards',
+ score: maxScore,
+ maxScore,
+ percentage: 100,
+ issues: [],
+ },
+ suggestions: [],
+ };
+ }
+
+ // If awards are present, give full score
+ return {
+ breakdown: {
+ category: 'Awards',
+ score: maxScore,
+ maxScore,
+ percentage: 100,
+ issues: [],
+ },
+ suggestions: [],
+ };
+ }
+
+ /**
+ * Check best practices
+ */
+ private static checkBestPractices(resume: Resume): ResumeSuggestion[] {
+ const suggestions: ResumeSuggestion[] = [];
+
+ // Check resume length
+ if (resume.metadata.page_count > 2) {
+ suggestions.push({
+ id: 'best-practice-length',
+ category: 'Best Practices',
+ severity: 'warning',
+ message: 'Consider keeping your resume to 1-2 pages for better readability',
+ });
+ }
+
+ // Check word count
+ if (resume.metadata.word_count < 200) {
+ suggestions.push({
+ id: 'best-practice-word-count-low',
+ category: 'Best Practices',
+ severity: 'warning',
+ message: 'Your resume seems sparse. Add more details to showcase your experience',
+ });
+ } else if (resume.metadata.word_count > 800) {
+ suggestions.push({
+ id: 'best-practice-word-count-high',
+ category: 'Best Practices',
+ severity: 'info',
+ message: 'Your resume is quite detailed. Consider being more concise',
+ });
+ }
+
+ // Check section order
+ const sectionOrder = resume.sections.map((s) => s.type);
+ if (sectionOrder[0] !== 'personal_info') {
+ suggestions.push({
+ id: 'best-practice-section-order',
+ category: 'Best Practices',
+ severity: 'info',
+ message: 'Consider placing Personal Information at the top of your resume',
+ });
+ }
+
+ return suggestions;
+ }
+
+ /**
+ * Prioritize suggestions by severity
+ */
+ private static prioritizeSuggestions(suggestions: ResumeSuggestion[]): ResumeSuggestion[] {
+ const severityOrder = { critical: 0, warning: 1, info: 2 };
+ return suggestions.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
+ }
+
+ /**
+ * Get section content length
+ */
+ static getSectionContentLength(section: ResumeSection): number {
+ if (section.type === 'personal_info') {
+ const content = section.content as PersonalInfo;
+ return Object.values(content).join(' ').length;
+ } else if (Array.isArray(section.content)) {
+ return JSON.stringify(section.content).length;
+ } else if (typeof section.content === 'object') {
+ const content = section.content as CustomContent;
+ return content.content?.length || 0;
+ }
+ return 0;
+ }
+
+ /**
+ * Check if section is empty
+ */
+ static isSectionEmpty(section: ResumeSection): boolean {
+ if (section.type === 'personal_info') {
+ const content = section.content as PersonalInfo;
+ return !content.full_name && !content.email && !content.phone;
+ } else if (Array.isArray(section.content)) {
+ return section.content.length === 0;
+ } else if (typeof section.content === 'object') {
+ const content = section.content as CustomContent;
+ return !content.content || content.content.trim().length === 0;
+ }
+ return true;
+ }
+
+ /**
+ * Check if section has minimal content
+ */
+ static hasMinimalContent(section: ResumeSection): boolean {
+ const length = this.getSectionContentLength(section);
+ return length > 0 && length < this.MIN_CONTENT_LENGTH;
+ }
+}
diff --git a/lib/storage/offline-storage.ts b/lib/storage/offline-storage.ts
new file mode 100644
index 000000000..0e7c7d2fb
--- /dev/null
+++ b/lib/storage/offline-storage.ts
@@ -0,0 +1,202 @@
+/**
+ * Offline Storage Utility
+ * Provides local storage fallback for resume data when offline
+ */
+
+import { Resume } from '@/types/resume';
+
+const STORAGE_KEY_PREFIX = 'resume_offline_';
+const PENDING_SAVES_KEY = 'resume_pending_saves';
+
+export interface PendingSave {
+ resumeId: string;
+ timestamp: number;
+ data: Resume;
+}
+
+/**
+ * Save resume to local storage
+ */
+export function saveToLocalStorage(resume: Resume): void {
+ try {
+ const key = `${STORAGE_KEY_PREFIX}${resume.id}`;
+ localStorage.setItem(key, JSON.stringify(resume));
+
+ // Add to pending saves queue
+ addToPendingSaves(resume);
+ } catch (error) {
+ console.error('Failed to save to local storage:', error);
+ }
+}
+
+/**
+ * Load resume from local storage
+ */
+export function loadFromLocalStorage(resumeId: string): Resume | null {
+ try {
+ const key = `${STORAGE_KEY_PREFIX}${resumeId}`;
+ const data = localStorage.getItem(key);
+
+ if (!data) return null;
+
+ return JSON.parse(data) as Resume;
+ } catch (error) {
+ console.error('Failed to load from local storage:', error);
+ return null;
+ }
+}
+
+/**
+ * Remove resume from local storage
+ */
+export function removeFromLocalStorage(resumeId: string): void {
+ try {
+ const key = `${STORAGE_KEY_PREFIX}${resumeId}`;
+ localStorage.removeItem(key);
+
+ // Remove from pending saves
+ removeFromPendingSaves(resumeId);
+ } catch (error) {
+ console.error('Failed to remove from local storage:', error);
+ }
+}
+
+/**
+ * Add resume to pending saves queue
+ */
+function addToPendingSaves(resume: Resume): void {
+ try {
+ const pendingSaves = getPendingSaves();
+
+ // Remove existing entry for this resume if any
+ const filtered = pendingSaves.filter((save) => save.resumeId !== resume.id);
+
+ // Add new entry
+ filtered.push({
+ resumeId: resume.id,
+ timestamp: Date.now(),
+ data: resume,
+ });
+
+ localStorage.setItem(PENDING_SAVES_KEY, JSON.stringify(filtered));
+ } catch (error) {
+ console.error('Failed to add to pending saves:', error);
+ }
+}
+
+/**
+ * Get all pending saves
+ */
+export function getPendingSaves(): PendingSave[] {
+ try {
+ const data = localStorage.getItem(PENDING_SAVES_KEY);
+
+ if (!data) return [];
+
+ return JSON.parse(data) as PendingSave[];
+ } catch (error) {
+ console.error('Failed to get pending saves:', error);
+ return [];
+ }
+}
+
+/**
+ * Remove resume from pending saves
+ */
+function removeFromPendingSaves(resumeId: string): void {
+ try {
+ const pendingSaves = getPendingSaves();
+ const filtered = pendingSaves.filter((save) => save.resumeId !== resumeId);
+
+ localStorage.setItem(PENDING_SAVES_KEY, JSON.stringify(filtered));
+ } catch (error) {
+ console.error('Failed to remove from pending saves:', error);
+ }
+}
+
+/**
+ * Clear all pending saves
+ */
+export function clearPendingSaves(): void {
+ try {
+ localStorage.removeItem(PENDING_SAVES_KEY);
+ } catch (error) {
+ console.error('Failed to clear pending saves:', error);
+ }
+}
+
+/**
+ * Check if there are pending saves
+ */
+export function hasPendingSaves(): boolean {
+ return getPendingSaves().length > 0;
+}
+
+/**
+ * Get storage usage information
+ */
+export function getStorageInfo(): {
+ used: number;
+ available: number;
+ percentage: number;
+} {
+ try {
+ let used = 0;
+
+ // Calculate used storage
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key?.startsWith(STORAGE_KEY_PREFIX) || key === PENDING_SAVES_KEY) {
+ const value = localStorage.getItem(key);
+ if (value) {
+ used += key.length + value.length;
+ }
+ }
+ }
+
+ // Estimate available storage (5MB typical limit)
+ const available = 5 * 1024 * 1024; // 5MB in bytes
+ const percentage = (used / available) * 100;
+
+ return {
+ used,
+ available,
+ percentage: Math.min(percentage, 100),
+ };
+ } catch (error) {
+ console.error('Failed to get storage info:', error);
+ return {
+ used: 0,
+ available: 0,
+ percentage: 0,
+ };
+ }
+}
+
+/**
+ * Clear old offline data (older than 7 days)
+ */
+export function clearOldOfflineData(): void {
+ try {
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
+ const pendingSaves = getPendingSaves();
+
+ // Filter out old saves
+ const recentSaves = pendingSaves.filter(
+ (save) => save.timestamp > sevenDaysAgo
+ );
+
+ // Remove old resume data from local storage
+ pendingSaves.forEach((save) => {
+ if (save.timestamp <= sevenDaysAgo) {
+ const key = `${STORAGE_KEY_PREFIX}${save.resumeId}`;
+ localStorage.removeItem(key);
+ }
+ });
+
+ // Update pending saves
+ localStorage.setItem(PENDING_SAVES_KEY, JSON.stringify(recentSaves));
+ } catch (error) {
+ console.error('Failed to clear old offline data:', error);
+ }
+}
diff --git a/lib/utils/debounce.ts b/lib/utils/debounce.ts
new file mode 100644
index 000000000..83af9c774
--- /dev/null
+++ b/lib/utils/debounce.ts
@@ -0,0 +1,78 @@
+/**
+ * Debounce utility for performance optimization
+ * Delays function execution until after a specified wait time has elapsed
+ * since the last time it was invoked
+ */
+
+export function debounce any>(
+ func: T,
+ wait: number
+): (...args: Parameters) => void {
+ let timeoutId: NodeJS.Timeout | null = null;
+
+ return function debounced(...args: Parameters) {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+
+ timeoutId = setTimeout(() => {
+ func(...args);
+ }, wait);
+ };
+}
+
+/**
+ * Debounce with promise support
+ * Returns a promise that resolves when the debounced function executes
+ */
+export function debounceAsync Promise>(
+ func: T,
+ wait: number
+): (...args: Parameters) => Promise> {
+ let timeoutId: NodeJS.Timeout | null = null;
+ let pendingPromise: Promise> | null = null;
+
+ return function debouncedAsync(...args: Parameters): Promise> {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+
+ if (!pendingPromise) {
+ pendingPromise = new Promise((resolve, reject) => {
+ timeoutId = setTimeout(async () => {
+ try {
+ const result = await func(...args);
+ resolve(result);
+ } catch (error) {
+ reject(error);
+ } finally {
+ pendingPromise = null;
+ }
+ }, wait);
+ });
+ }
+
+ return pendingPromise;
+ };
+}
+
+/**
+ * Throttle utility for performance optimization
+ * Ensures function is called at most once per specified time period
+ */
+export function throttle any>(
+ func: T,
+ limit: number
+): (...args: Parameters) => void {
+ let inThrottle: boolean = false;
+
+ return function throttled(...args: Parameters) {
+ if (!inThrottle) {
+ func(...args);
+ inThrottle = true;
+ setTimeout(() => {
+ inThrottle = false;
+ }, limit);
+ }
+ };
+}
diff --git a/lib/validation/resume-validation.ts b/lib/validation/resume-validation.ts
new file mode 100644
index 000000000..a7637ebe9
--- /dev/null
+++ b/lib/validation/resume-validation.ts
@@ -0,0 +1,504 @@
+/**
+ * Resume Form Validation Utilities
+ * Provides validation functions for resume fields
+ */
+
+export interface ValidationResult {
+ isValid: boolean;
+ error?: string;
+}
+
+/**
+ * Email validation
+ */
+export function validateEmail(email: string): ValidationResult {
+ if (!email) {
+ return { isValid: true }; // Empty is valid (optional field)
+ }
+
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+ if (!emailRegex.test(email)) {
+ return {
+ isValid: false,
+ error: 'Please enter a valid email address',
+ };
+ }
+
+ return { isValid: true };
+}
+
+/**
+ * URL validation
+ */
+export function validateURL(url: string, fieldName = 'URL'): ValidationResult {
+ if (!url) {
+ return { isValid: true }; // Empty is valid (optional field)
+ }
+
+ try {
+ const urlObj = new URL(url);
+
+ // Check if protocol is http or https
+ if (!['http:', 'https:'].includes(urlObj.protocol)) {
+ return {
+ isValid: false,
+ error: `${fieldName} must start with http:// or https://`,
+ };
+ }
+
+ return { isValid: true };
+ } catch {
+ return {
+ isValid: false,
+ error: `Please enter a valid ${fieldName}`,
+ };
+ }
+}
+
+/**
+ * LinkedIn URL validation
+ */
+export function validateLinkedInURL(url: string): ValidationResult {
+ if (!url) {
+ return { isValid: true }; // Empty is valid (optional field)
+ }
+
+ const basicValidation = validateURL(url, 'LinkedIn URL');
+ if (!basicValidation.isValid) {
+ return basicValidation;
+ }
+
+ // Check if it's a LinkedIn URL
+ if (!url.includes('linkedin.com/')) {
+ return {
+ isValid: false,
+ error: 'Please enter a valid LinkedIn profile URL',
+ };
+ }
+
+ return { isValid: true };
+}
+
+/**
+ * GitHub URL validation
+ */
+export function validateGitHubURL(url: string): ValidationResult {
+ if (!url) {
+ return { isValid: true }; // Empty is valid (optional field)
+ }
+
+ const basicValidation = validateURL(url, 'GitHub URL');
+ if (!basicValidation.isValid) {
+ return basicValidation;
+ }
+
+ // Check if it's a GitHub URL
+ if (!url.includes('github.com/')) {
+ return {
+ isValid: false,
+ error: 'Please enter a valid GitHub profile URL',
+ };
+ }
+
+ return { isValid: true };
+}
+
+/**
+ * Phone number validation (flexible format)
+ */
+export function validatePhoneNumber(phone: string): ValidationResult {
+ if (!phone) {
+ return { isValid: true }; // Empty is valid (optional field)
+ }
+
+ // Remove common formatting characters
+ const cleaned = phone.replace(/[\s\-\(\)\+\.]/g, '');
+
+ // Check if it contains only digits after cleaning
+ if (!/^\d+$/.test(cleaned)) {
+ return {
+ isValid: false,
+ error: 'Phone number should contain only digits and formatting characters',
+ };
+ }
+
+ // Check length (between 7 and 15 digits is reasonable for international numbers)
+ if (cleaned.length < 7 || cleaned.length > 15) {
+ return {
+ isValid: false,
+ error: 'Phone number should be between 7 and 15 digits',
+ };
+ }
+
+ return { isValid: true };
+}
+
+/**
+ * Date validation
+ */
+export function validateDate(date: string): ValidationResult {
+ if (!date) {
+ return { isValid: true }; // Empty is valid (optional field)
+ }
+
+ const dateObj = new Date(date);
+
+ if (isNaN(dateObj.getTime())) {
+ return {
+ isValid: false,
+ error: 'Please enter a valid date',
+ };
+ }
+
+ return { isValid: true };
+}
+
+/**
+ * Date range validation (start date must be before end date)
+ */
+export function validateDateRange(
+ startDate: string,
+ endDate: string | undefined,
+ isCurrent: boolean
+): ValidationResult {
+ // If current position/education, end date is not required
+ if (isCurrent) {
+ return { isValid: true };
+ }
+
+ if (!startDate) {
+ return {
+ isValid: false,
+ error: 'Start date is required',
+ };
+ }
+
+ const startValidation = validateDate(startDate);
+ if (!startValidation.isValid) {
+ return startValidation;
+ }
+
+ if (!endDate) {
+ return {
+ isValid: false,
+ error: 'End date is required (or mark as current)',
+ };
+ }
+
+ const endValidation = validateDate(endDate);
+ if (!endValidation.isValid) {
+ return endValidation;
+ }
+
+ const start = new Date(startDate);
+ const end = new Date(endDate);
+
+ if (start > end) {
+ return {
+ isValid: false,
+ error: 'Start date must be before end date',
+ };
+ }
+
+ // Check if dates are not too far in the future
+ const now = new Date();
+ const oneYearFromNow = new Date();
+ oneYearFromNow.setFullYear(now.getFullYear() + 1);
+
+ if (start > oneYearFromNow) {
+ return {
+ isValid: false,
+ error: 'Start date cannot be more than 1 year in the future',
+ };
+ }
+
+ return { isValid: true };
+}
+
+/**
+ * Required field validation
+ */
+export function validateRequired(value: string, fieldName = 'This field'): ValidationResult {
+ if (!value || value.trim() === '') {
+ return {
+ isValid: false,
+ error: `${fieldName} is required`,
+ };
+ }
+
+ return { isValid: true };
+}
+
+/**
+ * Minimum length validation
+ */
+export function validateMinLength(
+ value: string,
+ minLength: number,
+ fieldName = 'This field'
+): ValidationResult {
+ if (!value) {
+ return { isValid: true }; // Empty is valid (use validateRequired separately)
+ }
+
+ if (value.trim().length < minLength) {
+ return {
+ isValid: false,
+ error: `${fieldName} must be at least ${minLength} characters`,
+ };
+ }
+
+ return { isValid: true };
+}
+
+/**
+ * Maximum length validation
+ */
+export function validateMaxLength(
+ value: string,
+ maxLength: number,
+ fieldName = 'This field'
+): ValidationResult {
+ if (!value) {
+ return { isValid: true };
+ }
+
+ if (value.length > maxLength) {
+ return {
+ isValid: false,
+ error: `${fieldName} must be no more than ${maxLength} characters`,
+ };
+ }
+
+ return { isValid: true };
+}
+
+/**
+ * Validate all fields in personal info section
+ */
+export function validatePersonalInfo(data: {
+ full_name?: string;
+ email?: string;
+ phone?: string;
+ website?: string;
+ linkedin?: string;
+ github?: string;
+}): Record {
+ const errors: Record = {};
+
+ // Validate email
+ if (data.email) {
+ const emailValidation = validateEmail(data.email);
+ if (!emailValidation.isValid && emailValidation.error) {
+ errors.email = emailValidation.error;
+ }
+ }
+
+ // Validate phone
+ if (data.phone) {
+ const phoneValidation = validatePhoneNumber(data.phone);
+ if (!phoneValidation.isValid && phoneValidation.error) {
+ errors.phone = phoneValidation.error;
+ }
+ }
+
+ // Validate website
+ if (data.website) {
+ const websiteValidation = validateURL(data.website, 'Website');
+ if (!websiteValidation.isValid && websiteValidation.error) {
+ errors.website = websiteValidation.error;
+ }
+ }
+
+ // Validate LinkedIn
+ if (data.linkedin) {
+ const linkedinValidation = validateLinkedInURL(data.linkedin);
+ if (!linkedinValidation.isValid && linkedinValidation.error) {
+ errors.linkedin = linkedinValidation.error;
+ }
+ }
+
+ // Validate GitHub
+ if (data.github) {
+ const githubValidation = validateGitHubURL(data.github);
+ if (!githubValidation.isValid && githubValidation.error) {
+ errors.github = githubValidation.error;
+ }
+ }
+
+ return errors;
+}
+
+/**
+ * Validate education entry
+ */
+export function validateEducation(data: {
+ institution?: string;
+ degree?: string;
+ field?: string;
+ start_date?: string;
+ end_date?: string;
+ current?: boolean;
+}): Record {
+ const errors: Record = {};
+
+ // Validate required fields
+ if (!data.institution?.trim()) {
+ errors.institution = 'Institution is required';
+ }
+
+ if (!data.degree?.trim()) {
+ errors.degree = 'Degree is required';
+ }
+
+ // Validate date range
+ if (data.start_date) {
+ const dateRangeValidation = validateDateRange(
+ data.start_date,
+ data.end_date,
+ data.current || false
+ );
+ if (!dateRangeValidation.isValid && dateRangeValidation.error) {
+ errors.dates = dateRangeValidation.error;
+ }
+ }
+
+ return errors;
+}
+
+/**
+ * Validate experience entry
+ */
+export function validateExperience(data: {
+ company?: string;
+ position?: string;
+ start_date?: string;
+ end_date?: string;
+ current?: boolean;
+}): Record {
+ const errors: Record = {};
+
+ // Validate required fields
+ if (!data.company?.trim()) {
+ errors.company = 'Company is required';
+ }
+
+ if (!data.position?.trim()) {
+ errors.position = 'Position is required';
+ }
+
+ // Validate date range
+ if (data.start_date) {
+ const dateRangeValidation = validateDateRange(
+ data.start_date,
+ data.end_date,
+ data.current || false
+ );
+ if (!dateRangeValidation.isValid && dateRangeValidation.error) {
+ errors.dates = dateRangeValidation.error;
+ }
+ }
+
+ return errors;
+}
+
+/**
+ * Validate project entry
+ */
+export function validateProject(data: {
+ name?: string;
+ url?: string;
+ github?: string;
+ start_date?: string;
+ end_date?: string;
+}): Record {
+ const errors: Record = {};
+
+ // Validate required fields
+ if (!data.name?.trim()) {
+ errors.name = 'Project name is required';
+ }
+
+ // Validate URLs
+ if (data.url) {
+ const urlValidation = validateURL(data.url, 'Project URL');
+ if (!urlValidation.isValid && urlValidation.error) {
+ errors.url = urlValidation.error;
+ }
+ }
+
+ if (data.github) {
+ const githubValidation = validateGitHubURL(data.github);
+ if (!githubValidation.isValid && githubValidation.error) {
+ errors.github = githubValidation.error;
+ }
+ }
+
+ // Validate date range (optional for projects)
+ if (data.start_date && data.end_date) {
+ const dateRangeValidation = validateDateRange(data.start_date, data.end_date, false);
+ if (!dateRangeValidation.isValid && dateRangeValidation.error) {
+ errors.dates = dateRangeValidation.error;
+ }
+ }
+
+ return errors;
+}
+
+/**
+ * Validate certification entry
+ */
+export function validateCertification(data: {
+ name?: string;
+ issuer?: string;
+ date?: string;
+ expiry_date?: string;
+ url?: string;
+}): Record {
+ const errors: Record = {};
+
+ // Validate required fields
+ if (!data.name?.trim()) {
+ errors.name = 'Certification name is required';
+ }
+
+ if (!data.issuer?.trim()) {
+ errors.issuer = 'Issuer is required';
+ }
+
+ // Validate dates
+ if (data.date) {
+ const dateValidation = validateDate(data.date);
+ if (!dateValidation.isValid && dateValidation.error) {
+ errors.date = dateValidation.error;
+ }
+ }
+
+ if (data.expiry_date) {
+ const expiryValidation = validateDate(data.expiry_date);
+ if (!expiryValidation.isValid && expiryValidation.error) {
+ errors.expiry_date = expiryValidation.error;
+ }
+
+ // Check if expiry is after issue date
+ if (data.date && expiryValidation.isValid) {
+ const issueDate = new Date(data.date);
+ const expiryDate = new Date(data.expiry_date);
+
+ if (expiryDate < issueDate) {
+ errors.expiry_date = 'Expiry date must be after issue date';
+ }
+ }
+ }
+
+ // Validate URL
+ if (data.url) {
+ const urlValidation = validateURL(data.url, 'Certification URL');
+ if (!urlValidation.isValid && urlValidation.error) {
+ errors.url = urlValidation.error;
+ }
+ }
+
+ return errors;
+}
diff --git a/package-lock.json b/package-lock.json
index 1b3875325..c9e51992b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,7 +5,11 @@
"packages": {
"": {
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@google/generative-ai": "^0.24.1",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-dialog": "^1.1.14",
@@ -14,6 +18,7 @@
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
@@ -37,6 +42,7 @@
"clsx": "^2.1.1",
"critters": "^0.0.25",
"date-fns": "^4.1.0",
+ "docx": "^9.5.1",
"dompurify": "^3.2.6",
"dotenv": "^17.2.1",
"framer-motion": "^12.17.3",
@@ -820,6 +826,59 @@
"node": ">=10.0.0"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
@@ -2466,6 +2525,40 @@
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
+ "node_modules/@radix-ui/react-alert-dialog": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
+ "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dialog": "1.1.15",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@@ -2603,20 +2696,20 @@
}
},
"node_modules/@radix-ui/react-dialog": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
- "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
+ "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.10",
- "@radix-ui/react-focus-guards": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
@@ -2638,6 +2731,78 @@
}
}
},
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
+ "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
+ "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+ "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@@ -3054,6 +3219,45 @@
}
}
},
+ "node_modules/@radix-ui/react-slider": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
+ "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@@ -7300,6 +7504,12 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
"node_modules/cosmiconfig": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
@@ -8107,6 +8317,56 @@
"node": ">=0.10.0"
}
},
+ "node_modules/docx": {
+ "version": "9.5.1",
+ "resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz",
+ "integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^24.0.1",
+ "hash.js": "^1.1.7",
+ "jszip": "^3.10.1",
+ "nanoid": "^5.1.3",
+ "xml": "^1.0.1",
+ "xml-js": "^1.6.8"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/docx/node_modules/@types/node": {
+ "version": "24.9.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
+ "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/docx/node_modules/nanoid": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
+ "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.js"
+ },
+ "engines": {
+ "node": "^18 || >=20"
+ }
+ },
+ "node_modules/docx/node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "license": "MIT"
+ },
"node_modules/dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
@@ -9943,6 +10203,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/hash.js": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
+ "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "minimalistic-assert": "^1.0.1"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -12629,6 +12899,18 @@
"node": ">=4.0"
}
},
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
"node_modules/kapsule": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz",
@@ -13598,6 +13880,12 @@
"node": ">=4"
}
},
+ "node_modules/minimalistic-assert": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
+ "license": "ISC"
+ },
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -14770,6 +15058,12 @@
"license": "MIT",
"peer": true
},
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -15519,6 +15813,42 @@
"pify": "^2.3.0"
}
},
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/readable-stream/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/readable-stream/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/readable-stream/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -15979,6 +16309,12 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/sax": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
+ "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
+ "license": "ISC"
+ },
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -16132,6 +16468,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
"node_modules/sharp": {
"version": "0.34.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz",
@@ -18832,6 +19174,24 @@
}
}
},
+ "node_modules/xml": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
+ "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
+ "license": "MIT"
+ },
+ "node_modules/xml-js": {
+ "version": "1.6.11",
+ "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
+ "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": "^1.2.4"
+ },
+ "bin": {
+ "xml-js": "bin/cli.js"
+ }
+ },
"node_modules/xml-name-validator": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
diff --git a/package.json b/package.json
index 6b0af9594..13f7166fb 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,11 @@
"sync:reserved-usernames": "node scripts/sync-reserved-usernames.js"
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@google/generative-ai": "^0.24.1",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-dialog": "^1.1.14",
@@ -53,6 +57,7 @@
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
@@ -76,6 +81,7 @@
"clsx": "^2.1.1",
"critters": "^0.0.25",
"date-fns": "^4.1.0",
+ "docx": "^9.5.1",
"dompurify": "^3.2.6",
"dotenv": "^17.2.1",
"framer-motion": "^12.17.3",
diff --git a/types/resume.ts b/types/resume.ts
new file mode 100644
index 000000000..4122b987d
--- /dev/null
+++ b/types/resume.ts
@@ -0,0 +1,403 @@
+// Resume Builder Type Definitions
+
+export interface Resume {
+ id: string;
+ user_id: string;
+ title: string;
+ template_id: string;
+ sections: ResumeSection[];
+ styling: ResumeStyling;
+ metadata: ResumeMetadata;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ResumeSection {
+ id: string;
+ type: SectionType;
+ title: string;
+ order: number;
+ visible: boolean;
+ content: SectionContent;
+}
+
+export type SectionType =
+ | 'personal_info'
+ | 'education'
+ | 'experience'
+ | 'projects'
+ | 'skills'
+ | 'certifications'
+ | 'awards'
+ | 'custom';
+
+// Section Content Types
+
+export interface PersonalInfo {
+ full_name: string;
+ email: string;
+ phone: string;
+ location: string;
+ website?: string;
+ linkedin?: string;
+ github?: string;
+ summary?: string;
+}
+
+export interface Education {
+ id: string;
+ institution: string;
+ degree: string;
+ field: string;
+ start_date: string;
+ end_date?: string;
+ current: boolean;
+ gpa?: string;
+ achievements?: string[];
+}
+
+export interface Experience {
+ id: string;
+ company: string;
+ position: string;
+ location: string;
+ start_date: string;
+ end_date?: string;
+ current: boolean;
+ description: string;
+ achievements: string[];
+}
+
+export interface Project {
+ id: string;
+ name: string;
+ description: string;
+ technologies: string[];
+ url?: string;
+ github?: string;
+ start_date?: string;
+ end_date?: string;
+}
+
+export interface Skill {
+ category: string;
+ items: string[];
+}
+
+export interface Certification {
+ id: string;
+ name: string;
+ issuer: string;
+ date: string;
+ expiry_date?: string;
+ credential_id?: string;
+ url?: string;
+}
+
+export interface Award {
+ id: string;
+ title: string;
+ issuer: string;
+ date: string;
+ description?: string;
+}
+
+export interface CustomContent {
+ title: string;
+ content: string;
+}
+
+export type SectionContent =
+ | PersonalInfo
+ | Education[]
+ | Experience[]
+ | Project[]
+ | Skill[]
+ | Certification[]
+ | Award[]
+ | CustomContent;
+
+// Styling Configuration
+
+export interface ResumeStyling {
+ font_family: string;
+ font_size_body: number;
+ font_size_heading: number;
+ color_primary: string;
+ color_text: string;
+ color_accent: string;
+ margin_top: number;
+ margin_bottom: number;
+ margin_left: number;
+ margin_right: number;
+ line_height: number;
+ section_spacing: number;
+}
+
+// Resume Metadata
+
+export interface ResumeMetadata {
+ page_count: number;
+ word_count: number;
+ completeness_score: number;
+ last_exported?: string;
+ export_count: number;
+}
+
+// Database Types (for Supabase queries)
+
+export interface ResumeRow {
+ id: string;
+ user_id: string;
+ title: string;
+ template_id: string;
+ sections: unknown; // JSONB
+ styling: unknown; // JSONB
+ metadata: unknown; // JSONB
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ResumeInsert {
+ user_id: string;
+ title?: string;
+ template_id?: string;
+ sections?: unknown;
+ styling?: unknown;
+ metadata?: unknown;
+}
+
+export interface ResumeUpdate {
+ title?: string;
+ template_id?: string;
+ sections?: unknown;
+ styling?: unknown;
+ metadata?: unknown;
+}
+
+// Helper Types
+
+export interface SectionTypeInfo {
+ type: SectionType;
+ label: string;
+ icon: string;
+ defaultTitle: string;
+ description: string;
+}
+
+export const SECTION_TYPES: Record = {
+ personal_info: {
+ type: 'personal_info',
+ label: 'Personal Information',
+ icon: 'User',
+ defaultTitle: 'Personal Information',
+ description: 'Your contact details and summary',
+ },
+ education: {
+ type: 'education',
+ label: 'Education',
+ icon: 'GraduationCap',
+ defaultTitle: 'Education',
+ description: 'Your academic background',
+ },
+ experience: {
+ type: 'experience',
+ label: 'Work Experience',
+ icon: 'Briefcase',
+ defaultTitle: 'Work Experience',
+ description: 'Your professional experience',
+ },
+ projects: {
+ type: 'projects',
+ label: 'Projects',
+ icon: 'Code',
+ defaultTitle: 'Projects',
+ description: 'Your personal or professional projects',
+ },
+ skills: {
+ type: 'skills',
+ label: 'Skills',
+ icon: 'Wrench',
+ defaultTitle: 'Skills',
+ description: 'Your technical and soft skills',
+ },
+ certifications: {
+ type: 'certifications',
+ label: 'Certifications',
+ icon: 'Award',
+ defaultTitle: 'Certifications',
+ description: 'Your professional certifications',
+ },
+ awards: {
+ type: 'awards',
+ label: 'Awards & Honors',
+ icon: 'Trophy',
+ defaultTitle: 'Awards & Honors',
+ description: 'Your achievements and recognition',
+ },
+ custom: {
+ type: 'custom',
+ label: 'Custom Section',
+ icon: 'Plus',
+ defaultTitle: 'Custom Section',
+ description: 'Add any custom content',
+ },
+};
+
+// Template Types
+
+export type TemplateId = 'modern' | 'classic' | 'minimal' | 'creative' | 'executive';
+
+export interface TemplateInfo {
+ id: TemplateId;
+ name: string;
+ description: string;
+ thumbnail: string;
+ category: 'professional' | 'creative' | 'minimal';
+}
+
+export const TEMPLATES: Record = {
+ modern: {
+ id: 'modern',
+ name: 'Modern',
+ description: 'Clean, modern layout with purple accents',
+ thumbnail: '/templates/modern.png',
+ category: 'professional',
+ },
+ classic: {
+ id: 'classic',
+ name: 'Classic',
+ description: 'Traditional single-column layout',
+ thumbnail: '/templates/classic.png',
+ category: 'professional',
+ },
+ minimal: {
+ id: 'minimal',
+ name: 'Minimal',
+ description: 'Ultra-clean with maximum whitespace',
+ thumbnail: '/templates/minimal.png',
+ category: 'minimal',
+ },
+ creative: {
+ id: 'creative',
+ name: 'Creative',
+ description: 'Bold, colorful with unique structure',
+ thumbnail: '/templates/creative.png',
+ category: 'creative',
+ },
+ executive: {
+ id: 'executive',
+ name: 'Executive',
+ description: 'Professional layout for senior positions',
+ thumbnail: '/templates/executive.png',
+ category: 'professional',
+ },
+};
+
+// Validation Types
+
+export interface ValidationError {
+ field: string;
+ message: string;
+ type: 'required' | 'format' | 'length' | 'custom';
+}
+
+export interface ValidationResult {
+ valid: boolean;
+ errors: ValidationError[];
+}
+
+// Export Types
+
+export type ExportFormat = 'pdf' | 'docx' | 'json';
+
+export interface ExportOptions {
+ format: ExportFormat;
+ filename?: string;
+ includeMetadata?: boolean;
+}
+
+export interface ExportResult {
+ success: boolean;
+ blob?: Blob;
+ error?: string;
+ filename: string;
+}
+
+// Import Types
+
+export interface ImportOptions {
+ source: 'json' | 'linkedin';
+ data: string | object;
+ mergeStrategy?: 'replace' | 'merge';
+}
+
+export interface ImportResult {
+ success: boolean;
+ resume?: Resume;
+ fieldsPopulated?: number;
+ errors?: string[];
+}
+
+// Error Types
+
+export enum ResumeErrorCode {
+ LOAD_FAILED = 'LOAD_FAILED',
+ SAVE_FAILED = 'SAVE_FAILED',
+ EXPORT_FAILED = 'EXPORT_FAILED',
+ IMPORT_FAILED = 'IMPORT_FAILED',
+ VALIDATION_FAILED = 'VALIDATION_FAILED',
+ NETWORK_ERROR = 'NETWORK_ERROR',
+ UNAUTHORIZED = 'UNAUTHORIZED',
+ NOT_FOUND = 'NOT_FOUND',
+}
+
+export class ResumeError extends Error {
+ constructor(
+ message: string,
+ public code: ResumeErrorCode,
+ public details?: unknown
+ ) {
+ super(message);
+ this.name = 'ResumeError';
+ }
+}
+
+// Utility Types
+
+export type DeepPartial = {
+ [P in keyof T]?: T[P] extends object ? DeepPartial : T[P];
+};
+
+export type RequiredFields = T & Required>;
+
+// Default Values
+
+export const DEFAULT_STYLING: ResumeStyling = {
+ font_family: 'Inter',
+ font_size_body: 11,
+ font_size_heading: 16,
+ color_primary: '#8b5cf6',
+ color_text: '#1f2937',
+ color_accent: '#6366f1',
+ margin_top: 0.75,
+ margin_bottom: 0.75,
+ margin_left: 0.75,
+ margin_right: 0.75,
+ line_height: 1.5,
+ section_spacing: 1.25,
+};
+
+export const DEFAULT_METADATA: ResumeMetadata = {
+ page_count: 1,
+ word_count: 0,
+ completeness_score: 0,
+ export_count: 0,
+};
+
+export const DEFAULT_PERSONAL_INFO: PersonalInfo = {
+ full_name: '',
+ email: '',
+ phone: '',
+ location: '',
+};