diff --git a/apps/web/src/features/ats-checker/ai-review/ai-review-card.tsx b/apps/web/src/features/ats-checker/ai-review/ai-review-card.tsx deleted file mode 100644 index 25a150635..000000000 --- a/apps/web/src/features/ats-checker/ai-review/ai-review-card.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import type { PdfAtsReport } from "@reactive-resume/resume/ats-pdf"; -import type { AtsAiReview } from "./ai-review-results"; -import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { ArrowRightIcon, InfoIcon, SparkleIcon } from "@phosphor-icons/react"; -import { useMutation } from "@tanstack/react-query"; -import { Link } from "@tanstack/react-router"; -import { useState } from "react"; -import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert"; -import { Button } from "@reactive-resume/ui/components/button"; -import { Label } from "@reactive-resume/ui/components/label"; -import { Spinner } from "@reactive-resume/ui/components/spinner"; -import { toast } from "@reactive-resume/ui/components/toast"; -import { AiProviderPicker } from "@/features/settings/integrations/components/ai-provider-picker"; -import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider"; -import { getOrpcErrorMessage } from "@/libs/error-message"; -import { orpc } from "@/libs/orpc/client"; -import { getPdfFindingMessage } from "../messages"; -import { AiReviewResults } from "./ai-review-results"; - -/** Matches the procedure's input cap; the text is trimmed here so the request is never rejected. */ -const MAX_EXTRACTED_TEXT_CHARS = 50_000; -const MAX_FINDINGS = 120; - -type AiReviewCardProps = { - report: PdfAtsReport; - fullText: string; - jobDescription?: string; -}; - -/** - * Tier three: opt-in, per run, and only after the deterministic report already exists. - * - * Everything sent is stated on the card before the button is pressed. The PDF itself never leaves - * the browser. Only the text already extracted from it travels. - */ -export function AiReviewCard({ report, fullText, jobDescription }: AiReviewCardProps) { - const { usableProviders, hasUsableProvider, isLoading } = useHasUsableAiProvider(); - const [providerOverride, setProviderOverride] = useState(undefined); - const [review, setReview] = useState(null); - - const aiProviderId = providerOverride ?? usableProviders[0]?.id ?? null; - - const { mutate, isPending } = useMutation({ - ...orpc.ai.atsReview.mutationOptions(), - onSuccess: setReview, - onError: (error) => { - toast.add({ - type: "error", - description: getOrpcErrorMessage(error, { - byCode: { - BAD_GATEWAY: t`Your AI provider could not be reached. Check its settings and try again.`, - BAD_REQUEST: t`The provider returned a review that could not be read. Try again.`, - PRECONDITION_FAILED: t`AI providers are unavailable until ENCRYPTION_SECRET is configured.`, - }, - fallback: t`Failed to review this resume.`, - }), - }); - }, - }); - - if (isLoading) return null; - - if (!hasUsableProvider) return ; - - const trimmedText = fullText.slice(0, MAX_EXTRACTED_TEXT_CHARS).trim(); - - const onRun = () => { - if (!trimmedText) return; - - mutate({ - ...(aiProviderId ? { aiProviderId } : {}), - extractedText: trimmedText, - findings: report.findings.slice(0, MAX_FINDINGS).map((finding) => ({ - code: finding.code, - severity: finding.severity, - message: getPdfFindingMessage(finding.code).title, - })), - ...(jobDescription?.trim() ? { jobDescription: jobDescription.trim() } : {}), - }); - }; - - return ( -
-
- -

- Review the writing with AI -

-
- -

- - The checks above are mechanical. This asks a language model what a reader would think of your bullets, and - suggests rewrites. It produces no score. - -

- -

- {jobDescription?.trim() ? ( - - Sends the text already extracted from your PDF, plus the job description you pasted, to the AI provider you - choose below. The PDF file itself is never uploaded. - - ) : ( - - Sends the text already extracted from your PDF to the AI provider you choose below. The PDF file itself is - never uploaded. - - )} -

- -
- - -
- - - - {review && } -
- ); -} - -function NoProviderState() { - return ( - - - -

- - Connect your own AI provider to get a review of the writing. The checks above need no provider and no - account. - -

- - - - ); -} diff --git a/apps/web/src/features/ats-checker/checker.tsx b/apps/web/src/features/ats-checker/checker.tsx new file mode 100644 index 000000000..956421d40 --- /dev/null +++ b/apps/web/src/features/ats-checker/checker.tsx @@ -0,0 +1,449 @@ +import type { PdfAtsReport, PdfCategory } from "@reactive-resume/resume/ats-pdf"; +import type { CSSProperties } from "react"; +import type { ExtractProgress } from "./extract-client"; +import type { AtsCheckResult } from "./run-ats-check"; +import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useId, useRef, useState } from "react"; +import { Button } from "@reactive-resume/ui/components/button"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { Spinner } from "@reactive-resume/ui/components/spinner"; +import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs"; +import { Textarea } from "@reactive-resume/ui/components/textarea"; +import { cn } from "@reactive-resume/utils/style"; +import { ImportError, readResumeFile } from "@/features/resume/import/read-file"; +import { PdfViewer } from "@/features/resume/public/pdf-viewer"; +import { getOrpcErrorMessage } from "@/libs/error-message"; +import { client } from "@/libs/orpc/client"; +import { PdfPasswordRequiredError, PdfTooLargeError, PdfUnreadableError } from "./extract-client"; +import { getPdfCategoryLabel, getPdfFindingMessage } from "./messages"; +import { savePendingImport, takePendingImport } from "./pending-import"; +import { runAtsCheck } from "./run-ats-check"; + +const MAX_POSTING = 20_000; +const SAMPLE = { url: "/templates/pdf/onyx.pdf", name: "Sample resume.pdf" }; + +type State = + | { name: "idle"; error?: string } + | { name: "busy"; file: File; step: number } + | { name: "done"; file: File; result: AtsCheckResult } + | { name: "importing" }; + +const stepOf = (progress: ExtractProgress) => (progress.phase === "loading" ? 0 : progress.phase === "text" ? 1 : 2); + +function describeError(error: unknown) { + if (error instanceof PdfPasswordRequiredError) + return t`This PDF is password protected. Save an unprotected copy and try again.`; + if (error instanceof PdfTooLargeError) return t`That file is too large to check. PDFs up to 25 MB work.`; + if (error instanceof PdfUnreadableError) return t`That file couldn't be read as a PDF.`; + return t`Something went wrong while reading that file. Try again.`; +} + +type AtsCheckerProps = { + signedIn: boolean; + /** Back from signing up with a file waiting to be imported. */ + importPending: boolean; +}; + +/** + * The public checker: drop a PDF, watch three steps, then see the score, what could cost a match, and the file as + * software reads it. "Fix these in the editor" imports the same file (after signing up, if needed) and opens Check. + */ +export function AtsChecker({ signedIn, importPending }: AtsCheckerProps) { + const navigate = useNavigate(); + const [state, setState] = useState({ name: "idle" }); + const [posting, setPosting] = useState(""); + const abort = useRef(null); + + const check = async (file: File) => { + abort.current?.abort(); + const controller = new AbortController(); + abort.current = controller; + setState({ name: "busy", file, step: 0 }); + try { + const result = await runAtsCheck(file, { + jobDescription: posting, + signal: controller.signal, + onProgress: (progress) => setState({ name: "busy", file, step: stepOf(progress) }), + }); + if (controller.signal.aborted) return; + setState({ name: "done", file, result }); + } catch (error) { + if (!controller.signal.aborted) setState({ name: "idle", error: describeError(error) }); + } + }; + + const importAndOpen = async (file: File) => { + setState({ name: "importing" }); + try { + // Read in the browser, the same way the check did, so every issue lines up in Check. + const data = await readResumeFile(file, "pdf", { aiAvailable: false }); + const resumeId = await client.resume.import({ data }); + await navigate({ to: "/builder/$resumeId", params: { resumeId }, search: { mode: "check" } }); + } catch (error) { + setState({ + name: "idle", + error: + error instanceof ImportError + ? error.message + : getOrpcErrorMessage(error, { fallback: t`Couldn't import the file. Try again from Documents.` }), + }); + } + }; + + const fix = async (file: File) => { + if (signedIn) return importAndOpen(file); + await savePendingImport(file); + await navigate({ to: "/auth/register", search: { callbackURL: "/ats-checker?import=1" } }); + }; + + // Signed up (or in) with a file waiting: import it straight away. + const resumed = useRef(false); + useEffect(() => { + if (!signedIn || !importPending || resumed.current) return; + resumed.current = true; + void takePendingImport().then((file) => { + if (file) void importAndOpen(file); + else void navigate({ to: "/ats-checker", replace: true }); + }); + }); + + useEffect(() => () => abort.current?.abort(), []); + + if (state.name === "importing") + return ( +

+ + Importing it into your new resume… +

+ ); + + if (state.name === "busy") return ; + + if (state.name === "done") + return ( + void fix(state.file)} + onReset={() => setState({ name: "idle" })} + /> + ); + + return void check(file)} />; +} + +type IdleProps = { + error?: string | undefined; + posting: string; + onPosting: (value: string) => void; + onFile: (file: File) => void; +}; + +function Idle({ error, posting, onPosting, onFile }: IdleProps) { + const id = useId(); + const input = useRef(null); + const [dragging, setDragging] = useState(false); + + const sample = async () => { + const blob = await (await fetch(SAMPLE.url)).blob(); + onFile(new File([blob], SAMPLE.name, { type: "application/pdf" })); + }; + + return ( +
+
+

+ Can software read your resume? +

+

+ + Upload a PDF and see the text an applicant tracking system extracts: what's missing, what's out of order, + and how to fix it. Free, no account needed. + +

+
+ + {/* A drop zone, not a form: the card takes a dropped file; its buttons take the keyboard. */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: dropping is pointer-only; "choose a file" is the keyboard path. */} +
{ + event.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(event) => { + event.preventDefault(); + setDragging(false); + const file = event.dataTransfer.files[0]; + if (file) onFile(file); + }} + > + + + + Up to 25 MB · checked in your browser, never uploaded + + + { + const file = event.target.files?.[0]; + if (file) onFile(file); + event.target.value = ""; + }} + /> +
+ + {error && ( +

+ {error} +

+ )} + +
+ +