From d9ed63f720700ebda70e8ff297ab876d80b24937 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 00:50:20 +0200 Subject: [PATCH] feat(web): rebuild the public ATS checker around fixing the file Idle is a drop zone with "Check a sample file" and an optional posting; busy shows three labelled steps; the result is a 440px column with the score ring, the categories that could cost a match (and the posting's missing terms), and "Fix these in the editor", beside the file shown two ways: the original page and the text as software reads it. Phones get one column with the fix pinned. "Fix these in the editor" imports the same file, read in the browser as the check was, and opens it in Check. Visitors sign up first; the file waits in IndexedDB and is imported when they're back. The public checker's AI review is dropped (Check -> Writing covers it), with its locked card, the marketing parse preview and the old uploader. The PDF viewer also renders a given file, for the original page. --- .../ats-checker/ai-review/ai-review-card.tsx | 161 ------- .../ai-review/ai-review-results.tsx | 133 ------ .../ats-checker/ai-review/locked-card.tsx | 33 -- apps/web/src/features/ats-checker/checker.tsx | 449 ++++++++++++++++++ .../features/ats-checker/extract-client.ts | 2 +- .../features/ats-checker/parse-preview.tsx | 100 ---- .../features/ats-checker/pending-import.ts | 39 ++ apps/web/src/features/ats-checker/tool.tsx | 135 ------ .../web/src/features/ats-checker/uploader.tsx | 77 --- .../src/features/resume/public/pdf-viewer.tsx | 18 +- apps/web/src/routes/_home/ats-checker.tsx | 188 +------- 11 files changed, 520 insertions(+), 815 deletions(-) delete mode 100644 apps/web/src/features/ats-checker/ai-review/ai-review-card.tsx delete mode 100644 apps/web/src/features/ats-checker/ai-review/ai-review-results.tsx delete mode 100644 apps/web/src/features/ats-checker/ai-review/locked-card.tsx create mode 100644 apps/web/src/features/ats-checker/checker.tsx delete mode 100644 apps/web/src/features/ats-checker/parse-preview.tsx create mode 100644 apps/web/src/features/ats-checker/pending-import.ts delete mode 100644 apps/web/src/features/ats-checker/tool.tsx delete mode 100644 apps/web/src/features/ats-checker/uploader.tsx 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} +

+ )} + +
+ +