feat: complete release readiness work
- consolidate API ownership and remove dead vendor code - add Stripe billing, learning paths, and public CV hardening - add migration, recovery, security, audit, and browser gates
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, Box, LinearProgress, Paper, Skeleton, Stack, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, LinearProgress, Paper, Skeleton, Stack, Typography } from "@mui/material";
|
||||
import { api } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
@@ -12,6 +12,8 @@ type Usage = {
|
||||
storageLimitBytes: number;
|
||||
};
|
||||
|
||||
type BillingStatus = { enabled: boolean; canCheckout: boolean; canManage: boolean };
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1_000_000) return `${Math.round(bytes / 1_000)} KB`;
|
||||
if (bytes < 1_000_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
||||
@@ -21,6 +23,9 @@ function formatBytes(bytes: number) {
|
||||
export default function AiUsageCard() {
|
||||
const { t } = useI18n();
|
||||
const [usage, setUsage] = useState<Usage | null>(null);
|
||||
const [billing, setBilling] = useState<BillingStatus | null>(null);
|
||||
const [billingBusy, setBillingBusy] = useState(false);
|
||||
const [billingFailed, setBillingFailed] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,9 +33,24 @@ export default function AiUsageCard() {
|
||||
api.get<Usage>("/ai/usage")
|
||||
.then((response) => { if (active) setUsage(response.data); })
|
||||
.catch(() => { if (active) setFailed(true); });
|
||||
api.get<BillingStatus>("/billing/status")
|
||||
.then((response) => { if (active) setBilling(response.data); })
|
||||
.catch(() => { /* Billing is optional; usage remains available. */ });
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
const openBilling = async (path: "checkout" | "portal") => {
|
||||
setBillingBusy(true);
|
||||
setBillingFailed(false);
|
||||
try {
|
||||
const response = await api.post<{ url: string }>(`/billing/${path}`);
|
||||
window.location.assign(response.data.url);
|
||||
} catch {
|
||||
setBillingFailed(true);
|
||||
setBillingBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (failed) return <Alert severity="warning">{t("settingsUsageUnavailable")}</Alert>;
|
||||
if (!usage) return <Skeleton variant="rounded" height={150} />;
|
||||
|
||||
@@ -57,6 +77,9 @@ export default function AiUsageCard() {
|
||||
<LinearProgress variant="determinate" value={storagePercent} aria-label="Attachment storage used" sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>{t("settingsUsageReset")}</Typography>
|
||||
{billing?.canCheckout ? <Button sx={{ mt: 2 }} variant="contained" disabled={billingBusy} onClick={() => void openBilling("checkout")}>{t("settingsBillingUpgrade")}</Button> : null}
|
||||
{billing?.canManage ? <Button sx={{ mt: 2 }} variant="outlined" disabled={billingBusy} onClick={() => void openBilling("portal")}>{t("settingsBillingManage")}</Button> : null}
|
||||
{billingFailed ? <Alert severity="error" sx={{ mt: 2 }}>{t("settingsBillingUnavailable")}</Alert> : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -326,6 +326,24 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false));
|
||||
}, [open, jobId, tab, matchScore, matchScoreCache]);
|
||||
|
||||
const updateLearningRecommendation = useCallback(async (id: number, status: "done" | "dismissed") => {
|
||||
if (!jobId) return;
|
||||
try {
|
||||
await api.patch(`/jobapplications/${jobId}/checklist/${id}`, { status });
|
||||
setMatchScore(current => {
|
||||
if (!current) return current;
|
||||
const updated = {
|
||||
...current,
|
||||
learningRecommendations: (current.learningRecommendations ?? []).map(item => item.id === id ? { ...item, status } : item),
|
||||
};
|
||||
matchScoreCache.setCached(`${jobId}:match-score`, updated);
|
||||
return updated;
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("matchScoreLearningUpdateFailed")), "error");
|
||||
}
|
||||
}, [jobId, matchScoreCache, t, toast]);
|
||||
|
||||
// Suggest a status move from the latest inbound email when the workspace opens.
|
||||
useEffect(() => {
|
||||
if (!open || !jobId) return;
|
||||
@@ -1195,6 +1213,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
tab={tab}
|
||||
matchScore={matchScore}
|
||||
loadingMatchScore={loadingMatchScore}
|
||||
updateLearningRecommendation={updateLearningRecommendation}
|
||||
candidateFit={candidateFit}
|
||||
loadingCandidateFit={loadingCandidateFit}
|
||||
regenerateCandidateFit={regenerateCandidateFit}
|
||||
|
||||
@@ -8,7 +8,11 @@ function copyLines(items: string[]) {
|
||||
void navigator.clipboard.writeText(items.join("\n"));
|
||||
}
|
||||
|
||||
export function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) {
|
||||
export function MatchScoreCard({ score, loading, onUpdateLearningRecommendation }: {
|
||||
score: MatchScore | null;
|
||||
loading: boolean;
|
||||
onUpdateLearningRecommendation?: (id: number, status: "done" | "dismissed") => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (loading && !score) {
|
||||
@@ -25,6 +29,9 @@ export function MatchScoreCard({ score, loading }: { score: MatchScore | null; l
|
||||
const color: "success" | "warning" | "error" | "inherit" =
|
||||
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
|
||||
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
|
||||
const learningRecommendations = score.learningRecommendations ?? [];
|
||||
const visibleRecommendations = learningRecommendations.filter(item => item.status !== "dismissed");
|
||||
const dismissedCount = learningRecommendations.length - visibleRecommendations.length;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
|
||||
@@ -80,6 +87,28 @@ export function MatchScoreCard({ score, loading }: { score: MatchScore | null; l
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
{learningRecommendations.length ? (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="overline">{t("matchScoreLearningPath")}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>
|
||||
{t("matchScoreLearningPathHint")}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
||||
{visibleRecommendations.map(item => (
|
||||
<Box key={item.id} sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip size="small" label={item.keyword} color={item.status === "done" ? "success" : "warning"} variant={item.status === "done" ? "filled" : "outlined"} />
|
||||
{item.status === "done" ? <Typography variant="caption" color="success.main">{t("matchScoreLearned")}</Typography> : (
|
||||
<Box sx={{ display: "flex", gap: 0.75 }}>
|
||||
<Button size="small" onClick={() => onUpdateLearningRecommendation?.(item.id, "done")}>{t("matchScoreMarkLearned")}</Button>
|
||||
<Button size="small" color="inherit" onClick={() => onUpdateLearningRecommendation?.(item.id, "dismissed")}>{t("matchScoreDismiss")}</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{dismissedCount ? <Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreDismissedCount", { count: dismissedCount })}</Typography> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ type Props = {
|
||||
tab: number;
|
||||
matchScore: MatchScore | null;
|
||||
loadingMatchScore: boolean;
|
||||
updateLearningRecommendation: (id: number, status: "done" | "dismissed") => void;
|
||||
candidateFit: CandidateFit | null;
|
||||
loadingCandidateFit: boolean;
|
||||
regenerateCandidateFit: () => void;
|
||||
@@ -25,11 +26,11 @@ type Props = {
|
||||
|
||||
export default function JobInsightTabs(props: Props) {
|
||||
const { t } = useI18n();
|
||||
const { tab, matchScore, loadingMatchScore, candidateFit, loadingCandidateFit, regenerateCandidateFit, fitLevel, focusPlan, loadingFocusPlan, regenerateFocusPlan, interviewPrep, loadingInterviewPrep, regenerateInterviewPrep, readiness, loadingReadiness } = props;
|
||||
const { tab, matchScore, loadingMatchScore, updateLearningRecommendation, candidateFit, loadingCandidateFit, regenerateCandidateFit, fitLevel, focusPlan, loadingFocusPlan, regenerateFocusPlan, interviewPrep, loadingInterviewPrep, regenerateInterviewPrep, readiness, loadingReadiness } = props;
|
||||
return <>
|
||||
{tab === 5 && (
|
||||
<Box>
|
||||
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
|
||||
<MatchScoreCard score={matchScore} loading={loadingMatchScore} onUpdateLearningRecommendation={updateLearningRecommendation} />
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", my: 1.5 }}>
|
||||
<Button size="small" variant="outlined" disabled={loadingCandidateFit} onClick={regenerateCandidateFit}>
|
||||
{loadingCandidateFit ? "Regenerating..." : "Regenerate"}
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
|
||||
const [working, setWorking] = useState(false);
|
||||
const [pendingToken, setPendingToken] = useState<string | null>(null);
|
||||
|
||||
const clientId = (process.env.REACT_APP_MICROSOFT_CLIENT_ID || "").trim();
|
||||
const clientId = (process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID || "").trim();
|
||||
const signedIn = Boolean(me?.provider);
|
||||
const actionLabel = !signedIn
|
||||
? t("continueWithMicrosoft")
|
||||
|
||||
Reference in New Issue
Block a user