feat/Update_Controllers_to_Allow_for_Premium_Membership

This commit is contained in:
cesnimda
2026-08-03 09:17:28 +02:00
parent de937d25dc
commit c3f4a57195
187 changed files with 26062 additions and 991 deletions
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
@@ -36,6 +37,7 @@ import GradientButton from "./GradientButton";
import { useI18n } from "../i18n/I18nProvider";
import { useJobWorkspaceBaseData } from "./job-workspace/useJobWorkspaceBaseData";
import { useWorkspaceTabCache } from "./job-workspace/useWorkspaceTabCache";
import { useAccountPlan } from "../accountPlan";
type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview";
type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold";
@@ -134,6 +136,7 @@ function serializeTailoredDraft(draft: TailoredCvDraft) {
}
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode, onOpenWorkspace }: Props) {
const { canUseAi } = useAccountPlan();
const { toast } = useToast();
const { t } = useI18n();
const { confirmAction } = useDialogActions();
@@ -261,7 +264,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}, [open, jobId, tab, tailoredDraftCache]);
useEffect(() => {
if (!open || !jobId || tab !== 4) return;
if (!canUseAi || !open || !jobId || tab !== 4) return;
const cacheKey = `${jobId}:followup:${followUpMode}:${selectedAttachmentCsv || "none"}:${draftReloadToken}`;
const cached = followUpCache.getCached(cacheKey);
if (cached) {
@@ -278,10 +281,10 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
setDraftSubject(r.data.subject);
setDraftBody(r.data.body);
}).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false));
}, [open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv, followUpCache]);
}, [canUseAi, open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv, followUpCache]);
useEffect(() => {
if (!open || !jobId || tab !== 5 || candidateFit) return;
if (!canUseAi || !open || !jobId || tab !== 5 || candidateFit) return;
const cacheKey = `${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`;
const cached = candidateFitCache.getCached(cacheKey);
if (cached) {
@@ -294,19 +297,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
candidateFitCache.setCached(cacheKey, r.data);
setCandidateFit(r.data);
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
}, [canUseAi, open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
// Persisted server-side like interview prep (career-workspace-implementation-roadmap.md Phase
// F5); Regenerate is the explicit escape hatch when the job has changed since it was written.
const regenerateCandidateFit = useCallback(() => {
if (!jobId) return;
if (!canUseAi || !jobId) return;
setLoadingCandidateFit(true);
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
candidateFitCache.setCached(`${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`, r.data);
setCandidateFit(r.data);
toast("Candidate fit regenerated.", "success");
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate candidate fit."), "error")).finally(() => setLoadingCandidateFit(false));
}, [jobId, selectedAttachmentCsv, candidateFitCache, toast]);
}, [canUseAi, jobId, selectedAttachmentCsv, candidateFitCache, toast]);
// Match score is deterministic and cheap: load it on the Candidate Fit tab
// independently of the slow AI narrative so users see the number instantly.
@@ -370,7 +373,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
};
useEffect(() => {
if (!open || !jobId || tab !== 6 || focusPlan) return;
if (!canUseAi || !open || !jobId || tab !== 6 || focusPlan) return;
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
const cached = focusPlanCache.getCached(cacheKey);
if (cached) {
@@ -383,20 +386,20 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
focusPlanCache.setCached(cacheKey, r.data);
setFocusPlan(r.data);
}).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
}, [open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
}, [canUseAi, open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
const regenerateFocusPlan = useCallback(() => {
if (!jobId) return;
if (!canUseAi || !jobId) return;
setLoadingFocusPlan(true);
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
focusPlanCache.setCached(`${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`, r.data);
setFocusPlan(r.data);
toast("Focus plan regenerated.", "success");
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate focus plan."), "error")).finally(() => setLoadingFocusPlan(false));
}, [jobId, selectedAttachmentCsv, focusPlanCache, toast]);
}, [canUseAi, jobId, selectedAttachmentCsv, focusPlanCache, toast]);
useEffect(() => {
if (!open || !jobId || tab !== 7 || interviewPrep) return;
if (!canUseAi || !open || !jobId || tab !== 7 || interviewPrep) return;
const cacheKey = `${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`;
const cached = interviewPrepCache.getCached(cacheKey);
if (cached) {
@@ -405,24 +408,24 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}
setLoadingInterviewPrep(true);
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep/brief`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
interviewPrepCache.setCached(cacheKey, r.data);
setInterviewPrep(r.data);
}).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
}, [open, jobId, tab, interviewPrep, selectedAttachmentCsv, interviewPrepCache]);
}, [canUseAi, open, jobId, tab, interviewPrep, selectedAttachmentCsv, interviewPrepCache]);
// Interview prep is now persisted server-side (career-workspace-implementation-roadmap.md
// Phase F5) so it survives tab switches without re-running the AI call. Regenerate is the
// explicit escape hatch for when the underlying job/notes have changed since it was written.
const regenerateInterviewPrep = useCallback(() => {
if (!jobId) return;
if (!canUseAi || !jobId) return;
setLoadingInterviewPrep(true);
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep/brief`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
interviewPrepCache.setCached(`${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`, r.data);
setInterviewPrep(r.data);
toast("Interview prep regenerated.", "success");
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate interview prep."), "error")).finally(() => setLoadingInterviewPrep(false));
}, [jobId, selectedAttachmentCsv, interviewPrepCache, toast]);
}, [canUseAi, jobId, selectedAttachmentCsv, interviewPrepCache, toast]);
useEffect(() => {
setFollowUpDraft(null);
@@ -755,7 +758,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography variant="overline" sx={{ fontWeight: 700 }}>{t("jobDetailsStrategySnapshot")}</Typography>
<GradientButton size="small" disabled={loadingStrategySnapshot} onClick={async () => {
<GradientButton size="small" disabled={loadingStrategySnapshot || !canUseAi} onClick={async () => {
if (!jobId) return;
setLoadingStrategySnapshot(true);
try {
@@ -772,7 +775,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
} finally {
setLoadingStrategySnapshot(false);
}
}}>{loadingStrategySnapshot ? t("jobDetailsRefreshing") : t("jobDetailsGenerateStrategySnapshot")}</GradientButton>
}}>{loadingStrategySnapshot ? t("jobDetailsRefreshing") : canUseAi ? t("jobDetailsGenerateStrategySnapshot") : "Pro required"}</GradientButton>
</Box>
{candidateFit || focusPlan ? (
<Box sx={{ gridColumn: "1 / -1", p: 2, borderRadius: 4, backgroundColor: "background.paper", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
@@ -802,7 +805,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", flexWrap: "wrap", mb: 0.5 }}>
<Typography variant="overline">{t("jobDetailsSummaryAndSkills")}</Typography>
<Button size="small" variant="outlined" disabled={refreshingAi} onClick={async () => {
<Button size="small" variant="outlined" disabled={refreshingAi || !canUseAi} onClick={async () => {
if (!jobId) return;
if (!(await confirmAction(t("jobDetailsRefreshAiConfirm"), { title: t("jobDetailsRefreshAiTitle"), confirmLabel: t("jobDetailsRefreshAi") }))) return;
setRefreshingAi(true);
@@ -815,7 +818,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
} finally {
setRefreshingAi(false);
}
}}>{refreshingAi ? t("jobDetailsRefreshing") : t("jobDetailsRefreshAi")}</Button>
}}>{refreshingAi ? t("jobDetailsRefreshing") : canUseAi ? t("jobDetailsRefreshAi") : "Pro required"}</Button>
</Box>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{summaryFirstText}</Typography>
</Box>
@@ -896,7 +899,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}} />
</Button>
{customPhotoDataUrl ? <Button size="small" variant="text" onClick={() => setCustomPhotoDataUrl(null)}>Clear custom photo</Button> : null}
<Button size="small" variant="outlined" disabled={loadingTailoredCvDraft || generatingTailoredCvDraft} onClick={generateTailoredCvDraft}>{generatingTailoredCvDraft ? "Generating tailored draft..." : "Generate tailored draft"}</Button>
<Button size="small" variant="outlined" disabled={!canUseAi || loadingTailoredCvDraft || generatingTailoredCvDraft} onClick={generateTailoredCvDraft}>{generatingTailoredCvDraft ? "Generating tailored draft..." : canUseAi ? "Generate tailored draft" : "Pro required"}</Button>
<Button size="small" variant="outlined" disabled={loadingTailoredCvPreview} onClick={refreshTailoredCvPreview}>{loadingTailoredCvPreview ? "Building preview..." : "Preview PDF layout"}</Button>
<Button size="small" variant="outlined" disabled={exportingTailoredCvPdf} onClick={exportTailoredCvPdf}>{exportingTailoredCvPdf ? "Exporting PDF..." : "Download PDF"}</Button>
<Button size="small" variant="outlined" disabled={!hasUnsavedTailoredCvDraftChanges} onClick={resetTailoredCvDraftToSaved}>Reset to saved draft</Button>
@@ -1074,7 +1077,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<MenuItem value="bold">{t("jobDetailsCoverLetterStyleBold")}</MenuItem>
</Select>
</FormControl>
<Button size="small" variant="outlined" disabled={generatingPackage} onClick={async () => {
<Button size="small" variant="outlined" disabled={generatingPackage || !canUseAi} onClick={async () => {
if (!jobId) return;
setGeneratingPackage(true);
try {
@@ -1092,7 +1095,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
} finally {
setGeneratingPackage(false);
}
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}</Button>
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : canUseAi ? t("jobDetailsGeneratePackage") : "Pro required"}</Button>
<Button size="small" variant="outlined" disabled={!hasUnsavedPackageChanges} onClick={resetPackageWorkspaceToSaved}>Reset to saved</Button>
<Button size="small" variant="contained" disabled={savingApplicationDrafts} onClick={savePackageWorkspace}>{savingApplicationDrafts ? t("jobDetailsSaving") : "Save package drafts"}</Button>
</Box>
@@ -1209,6 +1212,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
</Box>
)}
{!canUseAi && [4, 5, 6, 7].includes(tab) && <Alert severity="info" sx={{ mb: 2 }} action={<Button href="/settings" size="small">View Pro</Button>}>AI assistance on this tab requires Pro. Non-AI job data and manual editing remain available.</Alert>}
<JobInsightTabs
tab={tab}
matchScore={matchScore}