Files
srbhr--resume-matcher/apps/frontend/components/builder/resume-builder.tsx
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

1179 lines
43 KiB
TypeScript

'use client';
import React, { useState, useEffect, Suspense, useCallback, useMemo } from 'react';
import Image from 'next/image';
import { useSearchParams, useRouter } from 'next/navigation';
import { type ResumeData } from '@/components/dashboard/resume-component';
import { ResumeForm } from './resume-form';
import { FormattingControls } from './formatting-controls';
import { CoverLetterEditor } from './cover-letter-editor';
import { OutreachEditor } from './outreach-editor';
import { CoverLetterPreview } from './cover-letter-preview';
import { OutreachPreview } from './outreach-preview';
import { GeneratePrompt } from './generate-prompt';
import { InterviewPrepView } from './interview-prep-view';
import { Button } from '@/components/ui/button';
import { RetroTabs } from '@/components/ui/retro-tabs';
import { ConfirmDialog, type ConfirmDialogProps } from '@/components/ui/confirm-dialog';
import {
Download,
Save,
AlertTriangle,
ArrowLeft,
RotateCcw,
Copy,
Check,
Sparkles,
Loader2,
} from 'lucide-react';
import {
useResumePreview,
type InterviewPrepData,
} from '@/components/common/resume_previewer_context';
import { PaginatedPreview } from '@/components/preview';
import {
downloadResumePdf,
downloadCoverLetterPdf,
getResumePdfUrl,
getCoverLetterPdfUrl,
fetchResume,
updateResume,
updateCoverLetter,
updateOutreachMessage,
generateCoverLetter,
generateOutreachMessage,
generateInterviewPrep,
fetchJobDescription,
} from '@/lib/api/resume';
import { JDComparisonView } from './jd-comparison-view';
import { RegenerateWizard } from './regenerate-wizard';
import { useRegenerateWizard } from '@/hooks/use-regenerate-wizard';
import { useTranslations } from '@/lib/i18n';
import { type TemplateSettings, DEFAULT_TEMPLATE_SETTINGS } from '@/lib/types/template-settings';
import { withLocalizedDefaultSections } from '@/lib/utils/section-helpers';
import { useLanguage } from '@/lib/context/language-context';
import { buildResumeFilename, downloadBlobAsFile, openUrlInNewTab } from '@/lib/utils/download';
import type { RegenerateItemInput } from '@/lib/api/enrichment';
type TabId = 'resume' | 'cover-letter' | 'outreach' | 'interview-prep' | 'jd-match';
type JobContextStatus = 'idle' | 'loading' | 'available' | 'missing';
const STORAGE_KEY = 'resume_builder_draft';
const SETTINGS_STORAGE_KEY = 'resume_builder_settings';
const TAB_IDS: TabId[] = ['resume', 'cover-letter', 'outreach', 'interview-prep', 'jd-match'];
type Translate = (key: string, params?: Record<string, string | number>) => string;
const getTabFromSearchParams = (searchParams: Pick<URLSearchParams, 'get'>): TabId => {
const tab = searchParams.get('tab');
return TAB_IDS.includes(tab as TabId) ? (tab as TabId) : 'resume';
};
const buildInitialData = (t: Translate): ResumeData => ({
personalInfo: {
name: t('builder.personalInfoForm.placeholders.name'),
title: t('builder.personalInfoForm.placeholders.title'),
email: t('builder.personalInfoForm.placeholders.email'),
phone: t('builder.personalInfoForm.placeholders.phone'),
location: t('builder.personalInfoForm.placeholders.location'),
website: t('builder.personalInfoForm.placeholders.website'),
linkedin: t('builder.personalInfoForm.placeholders.linkedin'),
github: t('builder.personalInfoForm.placeholders.github'),
},
summary: t('builder.placeholders.summary'),
workExperience: [],
education: [],
personalProjects: [],
additional: {
technicalSkills: [],
languages: [],
certificationsTraining: [],
awards: [],
},
});
const ResumeBuilderContent = () => {
const { t } = useTranslations();
const { uiLanguage, contentLanguage } = useLanguage();
const [notificationDialog, setNotificationDialog] = useState<{
title: string;
description: string;
variant: NonNullable<ConfirmDialogProps['variant']>;
} | null>(null);
const showNotification = useCallback(
(
description: string,
variant: NonNullable<ConfirmDialogProps['variant']> = 'default',
title?: string
) => {
const fallbackTitle = variant === 'success' ? t('common.success') : t('common.error');
setNotificationDialog({
title: title ?? fallbackTitle,
description,
variant,
});
},
[t]
);
const initialData = useMemo(() => buildInitialData(t), [t]);
const [resumeData, setResumeData] = useState<ResumeData>(() => initialData);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [lastSavedData, setLastSavedData] = useState<ResumeData>(() => initialData);
const [isSaving, setIsSaving] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [, setLoadingState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle');
const [templateSettings, setTemplateSettings] =
useState<TemplateSettings>(DEFAULT_TEMPLATE_SETTINGS);
const { improvedData } = useResumePreview();
const improvedPreview = improvedData?.data?.resume_preview;
const improvedCoverLetter = improvedData?.data?.cover_letter;
const improvedOutreach = improvedData?.data?.outreach_message;
const improvedInterviewPrep = improvedData?.data?.interview_prep ?? null;
const searchParams = useSearchParams();
const router = useRouter();
const resumeId = searchParams.get('id');
useEffect(() => {
if (resumeId || hasUnsavedChanges || improvedPreview) {
return;
}
const savedDraft = localStorage.getItem(STORAGE_KEY);
if (savedDraft) {
return;
}
setResumeData(initialData);
setLastSavedData(initialData);
}, [initialData, resumeId, hasUnsavedChanges, improvedPreview]);
// Tab state
const [activeTab, setActiveTab] = useState<TabId>(() => getTabFromSearchParams(searchParams));
useEffect(() => {
setActiveTab(getTabFromSearchParams(searchParams));
}, [searchParams]);
// Cover letter & outreach state
const [coverLetter, setCoverLetter] = useState('');
const [outreachMessage, setOutreachMessage] = useState('');
const [interviewPrep, setInterviewPrep] = useState<InterviewPrepData | null>(null);
const [isCoverLetterSaving, setIsCoverLetterSaving] = useState(false);
const [isOutreachSaving, setIsOutreachSaving] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const [resumeTitle, setResumeTitle] = useState<string | null>(null);
// On-demand generation state
const [isTailoredResume, setIsTailoredResume] = useState(false);
const [isGeneratingCoverLetter, setIsGeneratingCoverLetter] = useState(false);
const [isGeneratingOutreach, setIsGeneratingOutreach] = useState(false);
const [isGeneratingInterviewPrep, setIsGeneratingInterviewPrep] = useState(false);
const [interviewPrepError, setInterviewPrepError] = useState<string | null>(null);
const [showRegenerateDialog, setShowRegenerateDialog] = useState<
'cover-letter' | 'outreach' | 'interview-prep' | null
>(null);
// JD comparison state
const [jobDescription, setJobDescription] = useState<string | null>(null);
const [jobContextStatus, setJobContextStatus] = useState<JobContextStatus>('idle');
// AI Regenerate wizard
const regenerateWizard = useRegenerateWizard({
resumeId: resumeId || '',
outputLanguage: contentLanguage,
onSuccess: async () => {
// Reload resume data after applying changes
if (!resumeId) {
return;
}
try {
const data = await fetchResume(resumeId);
// Update resume title for downloads
setResumeTitle(data.title ?? null);
if (data.processed_resume) {
setResumeData(data.processed_resume as ResumeData);
setLastSavedData(data.processed_resume as ResumeData);
setHasUnsavedChanges(false);
}
} catch (error) {
console.error('Failed to reload resume after applying regenerated changes:', error);
showNotification(t('builder.alerts.reloadFailed'), 'danger');
throw error;
}
},
onError: (errorMessage) => {
console.error('Error during regeneration or applying regenerated changes:', errorMessage);
if (/network|fetch/i.test(errorMessage) || errorMessage.includes('Failed to fetch')) {
showNotification(t('builder.regenerate.errors.networkError'), 'danger');
return;
}
if (/resume content changed|uniquely matched|please regenerate/i.test(errorMessage)) {
showNotification(t('builder.regenerate.errors.resumeChanged'), 'danger');
return;
}
if (/generate/i.test(errorMessage)) {
showNotification(t('builder.regenerate.errors.generationFailed'), 'danger');
return;
}
showNotification(t('builder.regenerate.errors.applyFailed'), 'danger');
},
});
// Build regenerate items from resume data
const experienceItemsForRegenerate: RegenerateItemInput[] = useMemo(() => {
return (resumeData.workExperience || []).map((exp, idx) => ({
item_id: `exp_${idx}`,
item_type: 'experience' as const,
title: exp.title ?? '',
subtitle: exp.company || undefined,
current_content: Array.isArray(exp.description) ? exp.description : [],
}));
}, [resumeData.workExperience]);
const projectItemsForRegenerate: RegenerateItemInput[] = useMemo(() => {
return (resumeData.personalProjects || []).map((proj, idx) => ({
item_id: `proj_${idx}`,
item_type: 'project' as const,
title: proj.name ?? '',
subtitle: proj.role || undefined,
current_content: Array.isArray(proj.description) ? proj.description : [],
}));
}, [resumeData.personalProjects]);
const skillsItemForRegenerate: RegenerateItemInput | null = useMemo(() => {
const skills = resumeData.additional?.technicalSkills;
if (skills && skills.length > 0) {
return {
item_id: 'skills',
item_type: 'skills' as const,
title: t('builder.regenerate.selectDialog.skills'),
current_content: skills,
};
}
return null;
}, [resumeData.additional?.technicalSkills, t]);
const localizedResumeDataForPreview = useMemo(
() => withLocalizedDefaultSections(resumeData, t),
[resumeData, t]
);
// Load template settings from localStorage on mount
useEffect(() => {
const savedSettings = localStorage.getItem(SETTINGS_STORAGE_KEY);
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
setTemplateSettings({
...DEFAULT_TEMPLATE_SETTINGS,
...parsed,
margins: { ...DEFAULT_TEMPLATE_SETTINGS.margins, ...parsed.margins },
spacing: { ...DEFAULT_TEMPLATE_SETTINGS.spacing, ...parsed.spacing },
fontSize: { ...DEFAULT_TEMPLATE_SETTINGS.fontSize, ...parsed.fontSize },
});
} catch {
// Use defaults
}
}
}, []);
// Save template settings to localStorage when they change
useEffect(() => {
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(templateSettings));
}, [templateSettings]);
// Warn user before leaving with unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);
useEffect(() => {
const loadResumeData = async () => {
setLoadingState('loading');
// Priority 1: Fetch from API if ID is in URL (most reliable)
if (resumeId) {
try {
const data = await fetchResume(resumeId);
// Track if this is a tailored resume (has parent_id)
setIsTailoredResume(Boolean(data.parent_id));
// Store resume title for downloads
setResumeTitle(data.title ?? null);
// Load cover letter and outreach message if available
if (data.cover_letter) {
setCoverLetter(data.cover_letter);
}
if (data.outreach_message) {
setOutreachMessage(data.outreach_message);
}
setInterviewPrep(data.interview_prep ?? null);
setInterviewPrepError(null);
// Prefer processed_resume if available
if (data.processed_resume) {
setResumeData(data.processed_resume as ResumeData);
setLastSavedData(data.processed_resume as ResumeData);
setLoadingState('loaded');
return;
}
// Fallback to parsing raw content
if (data.raw_resume?.content) {
try {
const parsed = JSON.parse(data.raw_resume.content);
setResumeData(parsed);
setLastSavedData(parsed);
setLoadingState('loaded');
return;
} catch {
// Raw content is markdown, not JSON
}
}
} catch (err) {
console.error('Failed to load resume from API:', err);
}
}
// Priority 2: Improved Data from Context (Tailor Flow)
if (improvedPreview) {
setIsTailoredResume(Boolean(improvedData?.data?.resume_id && improvedData.data.job_id));
setResumeData(improvedPreview);
setLastSavedData(improvedPreview);
// Also load cover letter and outreach if present
if (improvedCoverLetter) {
setCoverLetter(improvedCoverLetter);
}
if (improvedOutreach) {
setOutreachMessage(improvedOutreach);
}
setInterviewPrep(improvedInterviewPrep);
setInterviewPrepError(null);
// Persist to localStorage as backup
localStorage.setItem(STORAGE_KEY, JSON.stringify(improvedPreview));
setLoadingState('loaded');
return;
}
// Priority 3: Restore from localStorage (browser refresh recovery)
const savedDraft = localStorage.getItem(STORAGE_KEY);
if (savedDraft) {
try {
const parsed = JSON.parse(savedDraft);
setResumeData(parsed);
setLastSavedData(parsed);
setHasUnsavedChanges(true); // Mark as unsaved since it's a draft
setLoadingState('loaded');
return;
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}
// Fallback: Use initial data
setLoadingState('loaded');
};
loadResumeData();
}, [
improvedData?.data?.job_id,
improvedData?.data?.resume_id,
improvedPreview,
improvedCoverLetter,
improvedOutreach,
improvedInterviewPrep,
resumeId,
]);
// Fetch job description when we have a tailored resume
useEffect(() => {
let cancelled = false;
const loadJobDescription = async () => {
if (isTailoredResume && resumeId) {
setJobDescription(null);
setJobContextStatus('loading');
try {
const data = await fetchJobDescription(resumeId);
if (!cancelled) {
setJobDescription(data.content);
setJobContextStatus('available');
}
} catch (err) {
// JD might not be available for older resumes
if (!cancelled) {
console.warn('Could not fetch job description:', err);
setJobDescription(null);
setJobContextStatus('missing');
}
}
} else {
// Clear job description when switching to non-tailored resume
setJobDescription(null);
setJobContextStatus('idle');
}
};
loadJobDescription();
return () => {
cancelled = true;
};
}, [isTailoredResume, resumeId]);
const handleUpdate = useCallback((newData: ResumeData) => {
setResumeData(newData);
setHasUnsavedChanges(true);
// Auto-save draft to localStorage
localStorage.setItem(STORAGE_KEY, JSON.stringify(newData));
}, []);
const handleSettingsChange = useCallback((newSettings: TemplateSettings) => {
setTemplateSettings(newSettings);
}, []);
const handleSave = async () => {
if (!resumeId) {
showNotification(t('builder.alerts.saveNotAvailable'), 'warning');
return;
}
try {
setIsSaving(true);
const updated = await updateResume(resumeId, resumeData);
const nextData = (updated.processed_resume || resumeData) as ResumeData;
setResumeData(nextData);
setLastSavedData(nextData);
setHasUnsavedChanges(false);
localStorage.setItem(STORAGE_KEY, JSON.stringify(nextData));
} catch (error) {
console.error('Failed to save resume:', error);
showNotification(t('builder.alerts.saveFailed'), 'danger');
} finally {
setIsSaving(false);
}
};
const handleReset = () => {
setResumeData(lastSavedData);
setHasUnsavedChanges(false);
localStorage.setItem(STORAGE_KEY, JSON.stringify(lastSavedData));
};
const getCompanyFromTitle = (title: string | null | undefined): string | null => {
if (!title) return null;
const atIdx = title.lastIndexOf(' @ ');
return atIdx !== -1 ? title.substring(atIdx + 3).trim() : null;
};
const handleDownload = async () => {
if (!resumeId) {
showNotification(t('builder.alerts.downloadNotAvailable'), 'warning');
return;
}
try {
setIsDownloading(true);
const blob = await downloadResumePdf(resumeId, templateSettings, uiLanguage);
const company = getCompanyFromTitle(resumeTitle);
const userName = resumeData.personalInfo?.name?.trim() || null;
const filename = buildResumeFilename(userName, company, resumeId, 'resume');
downloadBlobAsFile(blob, filename);
showNotification(t('builder.alerts.downloadSuccess'), 'success');
} catch (error) {
console.error('Failed to download resume:', error);
if (error instanceof TypeError && error.message.includes('Failed to fetch')) {
const fallbackUrl = getResumePdfUrl(resumeId, templateSettings, uiLanguage);
const didOpen = openUrlInNewTab(fallbackUrl);
if (!didOpen) {
showNotification(t('common.popupBlocked', { url: fallbackUrl }), 'warning');
}
return;
}
let errorMessage = t('builder.alerts.downloadFailed');
if (error instanceof Error && error.message) {
errorMessage = `${t('builder.alerts.downloadFailed')}: ${error.message}`;
}
showNotification(errorMessage, 'danger');
} finally {
setIsDownloading(false);
}
};
// Cover letter handlers
const handleSaveCoverLetter = async () => {
if (!resumeId) return;
try {
setIsCoverLetterSaving(true);
await updateCoverLetter(resumeId, coverLetter);
showNotification(t('builder.alerts.coverLetterSaveSuccess'), 'success');
} catch (error) {
console.error('Failed to save cover letter:', error);
showNotification(t('builder.alerts.coverLetterSaveFailed'), 'danger');
} finally {
setIsCoverLetterSaving(false);
}
};
const handleDownloadCoverLetter = async () => {
if (!resumeId) {
showNotification(t('builder.alerts.coverLetterDownloadRequiresResume'), 'warning');
return;
}
if (!coverLetter) {
showNotification(t('builder.alerts.coverLetterMissing'), 'warning');
return;
}
try {
setIsDownloading(true);
const blob = await downloadCoverLetterPdf(resumeId, templateSettings.pageSize, uiLanguage);
const company = getCompanyFromTitle(resumeTitle);
const userName = resumeData.personalInfo?.name?.trim() || null;
const filename = buildResumeFilename(userName, company, resumeId, 'cover-letter');
downloadBlobAsFile(blob, filename);
} catch (error) {
console.error('Failed to download cover letter:', error);
if (error instanceof TypeError && error.message.includes('Failed to fetch')) {
const fallbackUrl = getCoverLetterPdfUrl(resumeId, templateSettings.pageSize, uiLanguage);
const didOpen = openUrlInNewTab(fallbackUrl);
if (!didOpen) {
showNotification(t('common.popupBlocked', { url: fallbackUrl }), 'warning');
}
return;
}
const errorMessage = error instanceof Error ? error.message : t('common.unknown');
showNotification(
t('builder.alerts.coverLetterDownloadFailed', { error: errorMessage }),
'danger'
);
} finally {
setIsDownloading(false);
}
};
// Outreach handlers
const handleSaveOutreach = async () => {
if (!resumeId) return;
try {
setIsOutreachSaving(true);
await updateOutreachMessage(resumeId, outreachMessage);
showNotification(t('builder.alerts.outreachSaveSuccess'), 'success');
} catch (error) {
console.error('Failed to save outreach message:', error);
showNotification(t('builder.alerts.outreachSaveFailed'), 'danger');
} finally {
setIsOutreachSaving(false);
}
};
const handleCopyOutreach = async () => {
try {
await navigator.clipboard.writeText(outreachMessage);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
// On-demand generation handlers
const doGenerateCoverLetter = async () => {
if (!resumeId) return;
setIsGeneratingCoverLetter(true);
setShowRegenerateDialog(null);
try {
const content = await generateCoverLetter(resumeId);
setCoverLetter(content);
} catch (error) {
console.error('Failed to generate cover letter:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
showNotification(
t('builder.alerts.coverLetterGenerateFailed', { error: errorMessage }),
'danger'
);
} finally {
setIsGeneratingCoverLetter(false);
}
};
const handleGenerateCoverLetter = () => {
if (!resumeId) return;
// If content exists, show confirmation dialog
if (coverLetter) {
setShowRegenerateDialog('cover-letter');
return;
}
doGenerateCoverLetter();
};
const doGenerateOutreach = async () => {
if (!resumeId) return;
setIsGeneratingOutreach(true);
setShowRegenerateDialog(null);
try {
const content = await generateOutreachMessage(resumeId);
setOutreachMessage(content);
} catch (error) {
console.error('Failed to generate outreach message:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
showNotification(
t('builder.alerts.outreachGenerateFailed', { error: errorMessage }),
'danger'
);
} finally {
setIsGeneratingOutreach(false);
}
};
const handleGenerateOutreach = () => {
if (!resumeId) return;
// If content exists, show confirmation dialog
if (outreachMessage) {
setShowRegenerateDialog('outreach');
return;
}
doGenerateOutreach();
};
const canGenerateInterviewPrep =
Boolean(resumeId) && isTailoredResume && jobContextStatus === 'available';
const interviewPrepUnavailableMessage = !resumeId
? t('interviewPrep.saveRequiredDescription')
: jobContextStatus === 'loading'
? t('interviewPrep.loadingContextDescription')
: jobContextStatus === 'missing'
? t('interviewPrep.missingContextDescription')
: null;
const doGenerateInterviewPrep = async () => {
if (!canGenerateInterviewPrep || !resumeId) return;
setIsGeneratingInterviewPrep(true);
setInterviewPrepError(null);
setShowRegenerateDialog(null);
try {
const content = await generateInterviewPrep(resumeId);
setInterviewPrep(content);
} catch (error) {
console.error('Failed to generate interview preparation:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setInterviewPrepError(
t('builder.alerts.interviewPrepGenerateFailed', { error: errorMessage })
);
showNotification(
t('builder.alerts.interviewPrepGenerateFailed', { error: errorMessage }),
'danger'
);
} finally {
setIsGeneratingInterviewPrep(false);
}
};
const handleGenerateInterviewPrep = () => {
if (!canGenerateInterviewPrep) return;
if (interviewPrep) {
setShowRegenerateDialog('interview-prep');
return;
}
doGenerateInterviewPrep();
};
const regenerateDialogContentTitle =
showRegenerateDialog === 'cover-letter'
? t('coverLetter.title')
: showRegenerateDialog === 'outreach'
? t('outreach.title')
: t('interviewPrep.title');
const regenerateDialogConfirmLabel =
showRegenerateDialog === 'cover-letter'
? t('coverLetter.regenerate')
: showRegenerateDialog === 'outreach'
? t('outreach.regenerate')
: t('interviewPrep.regenerate');
const handleConfirmRegenerate = () => {
if (showRegenerateDialog === 'cover-letter') {
doGenerateCoverLetter();
} else if (showRegenerateDialog === 'outreach') {
doGenerateOutreach();
} else if (showRegenerateDialog === 'interview-prep') {
doGenerateInterviewPrep();
}
};
return (
<div className="h-screen w-full bg-background flex justify-center items-center p-4 md:p-8">
{/* Main Container */}
<div className="w-full h-full max-w-[90%] md:max-w-[95%] xl:max-w-[1800px] border border-black bg-background shadow-sw-lg flex flex-col">
{/* Header Section */}
<div className="border-b border-black p-6 md:p-8 bg-background no-print">
{/* Top Row: Back button and Actions */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<Button
variant="link"
onClick={() => router.push('/dashboard')}
className="mb-2 -ml-1"
>
<ArrowLeft className="w-4 h-4" />
{t('nav.backToDashboard')}
</Button>
<h1 className="font-serif text-3xl md:text-5xl text-black tracking-tight leading-[0.95] uppercase">
{t('nav.builder')}
</h1>
<div className="mt-3 flex items-center gap-3">
<p className="text-sm font-mono text-blue-700 uppercase tracking-wide font-bold">
{'// '}
{resumeId ? t('builder.editMode') : t('builder.createAndPreview')}
</p>
{hasUnsavedChanges && (
<span className="flex items-center gap-1 text-xs font-mono text-amber-600 bg-amber-50 px-2 py-1 border border-amber-200">
<AlertTriangle className="w-3 h-3" />
{t('builder.unsavedDraft')}
</span>
)}
</div>
</div>
<div className="flex gap-3 mt-4 md:mt-0">
{/* Resume tab actions */}
{activeTab === 'resume' && (
<>
<Button
variant="outline"
size="sm"
onClick={() => regenerateWizard.startRegenerate()}
disabled={!resumeId}
>
<Sparkles className="w-4 h-4" />
{t('builder.regenerate.buttonLabel')}
</Button>
<Button
variant="warning"
size="sm"
onClick={handleReset}
disabled={!hasUnsavedChanges}
>
<RotateCcw className="w-4 h-4" />
{t('common.reset')}
</Button>
<Button size="sm" onClick={handleSave} disabled={!resumeId || isSaving}>
<Save className="w-4 h-4" />
{isSaving ? t('common.saving') : t('common.save')}
</Button>
<Button
variant="success"
size="sm"
onClick={handleDownload}
disabled={!resumeId || isDownloading}
>
<Download className="w-4 h-4" />
{isDownloading ? t('common.generating') : t('common.download')}
</Button>
</>
)}
{/* Cover letter tab actions */}
{activeTab === 'cover-letter' && coverLetter && (
<>
<Button
variant="outline"
size="sm"
onClick={handleGenerateCoverLetter}
disabled={isGeneratingCoverLetter}
>
{isGeneratingCoverLetter ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
{t('coverLetter.regenerate')}
</Button>
<Button
variant="success"
size="sm"
onClick={handleDownloadCoverLetter}
disabled={!resumeId || isDownloading}
>
<Download className="w-4 h-4" />
{isDownloading ? t('common.generating') : t('common.download')}
</Button>
</>
)}
{/* Outreach tab actions */}
{activeTab === 'outreach' && outreachMessage && (
<>
<Button
variant="outline"
size="sm"
onClick={handleGenerateOutreach}
disabled={isGeneratingOutreach}
>
{isGeneratingOutreach ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
{t('outreach.regenerate')}
</Button>
<Button variant="success" size="sm" onClick={handleCopyOutreach}>
{isCopied ? (
<>
<Check className="w-4 h-4" />
{t('outreach.copied')}
</>
) : (
<>
<Copy className="w-4 h-4" />
{t('outreach.copyToClipboard')}
</>
)}
</Button>
</>
)}
{/* Interview prep tab actions */}
{activeTab === 'interview-prep' && interviewPrep && (
<Button
variant="outline"
size="sm"
onClick={handleGenerateInterviewPrep}
disabled={!canGenerateInterviewPrep || isGeneratingInterviewPrep}
>
{isGeneratingInterviewPrep ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
{t('interviewPrep.regenerate')}
</Button>
)}
</div>
</div>
</div>
{/* Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 bg-black gap-[1px] flex-1 min-h-0">
{/* Left Panel: Editor */}
<div className="bg-background p-6 md:p-8 overflow-y-auto no-print">
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-2 border-b-2 border-black pb-2">
<div className="w-3 h-3 bg-blue-700"></div>
<h2 className="font-mono text-lg font-bold uppercase tracking-wider">
{activeTab === 'resume' && t('builder.leftPanel.editorPanel')}
{activeTab === 'cover-letter' && t('builder.leftPanel.coverLetterEditor')}
{activeTab === 'outreach' && t('builder.leftPanel.outreachEditor')}
{activeTab === 'interview-prep' && t('builder.leftPanel.interviewPrep')}
{activeTab === 'jd-match' && t('builder.leftPanel.jdMatchAnalysis')}
</h2>
</div>
{/* Resume Editor */}
{activeTab === 'resume' && (
<>
<FormattingControls settings={templateSettings} onChange={handleSettingsChange} />
<ResumeForm resumeData={resumeData} onUpdate={handleUpdate} />
</>
)}
{/* Cover Letter Editor */}
{activeTab === 'cover-letter' &&
(coverLetter ? (
<CoverLetterEditor
content={coverLetter}
onChange={setCoverLetter}
onSave={handleSaveCoverLetter}
isSaving={isCoverLetterSaving}
/>
) : (
<GeneratePrompt
type="cover-letter"
isGenerating={isGeneratingCoverLetter}
onGenerate={handleGenerateCoverLetter}
isTailoredResume={isTailoredResume}
/>
))}
{/* Outreach Editor */}
{activeTab === 'outreach' &&
(outreachMessage ? (
<OutreachEditor
content={outreachMessage}
onChange={setOutreachMessage}
onSave={handleSaveOutreach}
isSaving={isOutreachSaving}
/>
) : (
<GeneratePrompt
type="outreach"
isGenerating={isGeneratingOutreach}
onGenerate={handleGenerateOutreach}
isTailoredResume={isTailoredResume}
/>
))}
{/* Interview Prep Read-Only View */}
{activeTab === 'interview-prep' && (
<InterviewPrepView
interviewPrep={interviewPrep}
isGenerating={isGeneratingInterviewPrep}
error={interviewPrepError}
onGenerate={handleGenerateInterviewPrep}
isTailoredResume={isTailoredResume}
canGenerate={canGenerateInterviewPrep}
unavailableMessage={interviewPrepUnavailableMessage}
className="p-0"
/>
)}
{/* JD Match Info Panel */}
{activeTab === 'jd-match' && (
<div className="space-y-4">
<div className="border-2 border-black bg-white p-4">
<h3 className="font-mono text-sm font-bold uppercase mb-2">
{t('builder.jdMatch.aboutTitle')}
</h3>
<p className="text-sm text-ink-soft leading-relaxed">
{t('builder.jdMatch.aboutDescription')}
</p>
</div>
<div className="border-2 border-black bg-background p-4">
<h3 className="font-mono text-sm font-bold uppercase mb-2">
{t('builder.jdMatch.highlightedKeywordsTitle')}
</h3>
<p className="text-sm text-ink-soft leading-relaxed">
{(() => {
const template = t(
'builder.jdMatch.highlightedKeywordsDescriptionTemplate'
);
const parts = template.split('__COLOR__');
if (parts.length < 2) return template;
return (
<>
{parts[0]}
<mark className="bg-yellow-200 px-1">
{t('builder.jdMatch.highlightColor')}
</mark>
{parts.slice(1).join('__COLOR__')}
</>
);
})()}
</p>
</div>
<div className="border-2 border-black bg-white p-4">
<h3 className="font-mono text-sm font-bold uppercase mb-2">
{t('builder.jdMatch.tipsTitle')}
</h3>
<ul className="text-sm text-ink-soft space-y-1 list-disc list-inside">
<li>{t('builder.jdMatch.tips.items.addMissingKeywords')}</li>
<li>{t('builder.jdMatch.tips.items.focusTechnicalSkills')}</li>
<li>{t('builder.jdMatch.tips.items.matchActionVerbs')}</li>
</ul>
</div>
</div>
)}
</div>
</div>
{/* Right Panel: Preview with Tabs */}
<div className="bg-secondary overflow-hidden flex flex-col no-print">
{/* Tabs Header */}
<div className="px-6 pt-3 shrink-0 bg-secondary">
<RetroTabs
tabs={[
{ id: 'resume', label: t('builder.previewTabs.resume') },
{
id: 'cover-letter',
label: t('builder.previewTabs.coverLetter'),
disabled: !coverLetter,
},
{
id: 'outreach',
label: t('builder.previewTabs.outreach'),
disabled: !outreachMessage,
},
{
id: 'interview-prep',
label: t('builder.previewTabs.interviewPrep'),
disabled: !isTailoredResume,
},
{
id: 'jd-match',
label: t('builder.previewTabs.jdMatch'),
disabled: !jobDescription,
},
]}
activeTab={activeTab}
onTabChange={(id) => setActiveTab(id as TabId)}
/>
</div>
{/* Preview Content */}
<div className="flex-1 overflow-y-auto">
{/* Resume Preview */}
{activeTab === 'resume' && (
<PaginatedPreview
resumeData={localizedResumeDataForPreview}
settings={templateSettings}
/>
)}
{/* Cover Letter Preview */}
{activeTab === 'cover-letter' &&
(coverLetter && resumeData.personalInfo ? (
<div className="p-6">
<CoverLetterPreview
content={coverLetter}
personalInfo={resumeData.personalInfo}
pageSize={templateSettings.pageSize}
/>
</div>
) : (
<GeneratePrompt
type="cover-letter"
isGenerating={isGeneratingCoverLetter}
onGenerate={handleGenerateCoverLetter}
isTailoredResume={isTailoredResume}
/>
))}
{/* Outreach Preview */}
{activeTab === 'outreach' &&
(outreachMessage ? (
<div className="p-6">
<OutreachPreview content={outreachMessage} />
</div>
) : (
<GeneratePrompt
type="outreach"
isGenerating={isGeneratingOutreach}
onGenerate={handleGenerateOutreach}
isTailoredResume={isTailoredResume}
/>
))}
{/* Interview Prep Preview */}
{activeTab === 'interview-prep' && (
<InterviewPrepView
interviewPrep={interviewPrep}
isGenerating={isGeneratingInterviewPrep}
error={interviewPrepError}
onGenerate={handleGenerateInterviewPrep}
isTailoredResume={isTailoredResume}
canGenerate={canGenerateInterviewPrep}
unavailableMessage={interviewPrepUnavailableMessage}
/>
)}
{/* JD Match Comparison */}
{activeTab === 'jd-match' && jobDescription && (
<JDComparisonView jobDescription={jobDescription} resumeData={resumeData} />
)}
</div>
</div>
</div>
{/* Footer */}
<div className="p-4 bg-background flex justify-between items-center font-mono text-xs text-blue-700 border-t border-black no-print">
<span className="uppercase font-bold flex items-center gap-2">
<Image
src="/logo.svg"
alt="Resume Matcher"
width={20}
height={20}
className="w-5 h-5"
/>
{t('builder.footer.moduleLabel')}
</span>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-700"></div>
<span className="uppercase">
{templateSettings.template === 'swiss-single' ||
templateSettings.template === 'modern' ||
templateSettings.template === 'latex' ||
templateSettings.template === 'clean'
? t('builder.footer.singleColumn')
: t('builder.footer.twoColumn')}
</span>
</div>
<span className="text-steel-grey">|</span>
<span className="uppercase">
{templateSettings.pageSize === 'A4' ? 'A4' : t('builder.pageSize.usLetter')}
</span>
</div>
</div>
</div>
{/* Regenerate Confirmation Dialog */}
<ConfirmDialog
open={showRegenerateDialog !== null}
onOpenChange={(open) => !open && setShowRegenerateDialog(null)}
title={t('builder.regenerateDialog.title', {
title: regenerateDialogContentTitle,
})}
description={t('builder.regenerateDialog.description', {
title: regenerateDialogContentTitle,
})}
confirmLabel={regenerateDialogConfirmLabel}
cancelLabel={t('common.cancel')}
variant="warning"
onConfirm={handleConfirmRegenerate}
/>
{/* Notification Dialog (replaces native alert()) */}
<ConfirmDialog
open={notificationDialog !== null}
onOpenChange={(open) => !open && setNotificationDialog(null)}
title={notificationDialog?.title ?? ''}
description={notificationDialog?.description ?? ''}
confirmLabel={t('common.ok')}
showCancelButton={false}
variant={notificationDialog?.variant ?? 'default'}
onConfirm={() => setNotificationDialog(null)}
/>
{/* AI Regenerate Wizard */}
<RegenerateWizard
step={regenerateWizard.step}
onStepChange={regenerateWizard.setStep}
experienceItems={experienceItemsForRegenerate}
projectItems={projectItemsForRegenerate}
skillsItem={skillsItemForRegenerate}
selectedItems={regenerateWizard.selectedItems}
onSelectionChange={regenerateWizard.setSelectedItems}
instruction={regenerateWizard.instruction}
onInstructionChange={regenerateWizard.setInstruction}
regeneratedItems={regenerateWizard.regeneratedItems}
regenerateErrors={regenerateWizard.regenerateErrors}
isGenerating={regenerateWizard.isGenerating}
isApplying={regenerateWizard.isApplying}
error={regenerateWizard.error}
onGenerate={regenerateWizard.generate}
onAccept={regenerateWizard.acceptChanges}
onReject={regenerateWizard.rejectAndRegenerate}
onClose={regenerateWizard.reset}
/>
</div>
);
};
export const ResumeBuilder = () => {
const { t } = useTranslations();
return (
<Suspense fallback={<div>{t('common.loading')}</div>}>
<ResumeBuilderContent />
</Suspense>
);
};