diff --git a/apps/web/src/features/ats-checker/checker.tsx b/apps/web/src/features/ats-checker/checker.tsx index 770c15524..36cf6ea11 100644 --- a/apps/web/src/features/ats-checker/checker.tsx +++ b/apps/web/src/features/ats-checker/checker.tsx @@ -138,6 +138,15 @@ export function AtsChecker({ signedIn, importPending }: AtsCheckerProps) { ); } +async function fetchSample() { + try { + const res = await fetch(SAMPLE.url); + return res.ok ? await res.blob() : null; + } catch { + return null; + } +} + type IdleProps = { error?: string | undefined; posting: string; @@ -150,9 +159,13 @@ function Idle({ error, posting, onPosting, onFile }: IdleProps) { const input = useRef(null); const [dragging, setDragging] = useState(false); + const [sampleError, setSampleError] = useState(); + const sample = async () => { - const blob = await (await fetch(SAMPLE.url)).blob(); - onFile(new File([blob], SAMPLE.name, { type: "application/pdf" })); + setSampleError(undefined); + const blob = await fetchSample(); + if (blob) onFile(new File([blob], SAMPLE.name, { type: "application/pdf" })); + else setSampleError(t`Couldn't load the sample file. Try again.`); }; return ( @@ -217,9 +230,9 @@ function Idle({ error, posting, onPosting, onFile }: IdleProps) { /> - {error && ( + {(error ?? sampleError) && (

- {error} + {error ?? sampleError}

)} @@ -278,7 +291,7 @@ const verdict = (score: number) => type Row = { key: string; title: string; count: string; tone: "danger" | "warn" | "accent"; body: React.ReactNode }; /** One row per category with something to fix, worst first, plus the posting's terms when one was pasted. */ -export function issueRows(report: PdfAtsReport): Row[] { +function issueRows(report: PdfAtsReport): Row[] { const byCategory = new Map(); for (const finding of report.findings) byCategory.set(finding.category, [...(byCategory.get(finding.category) ?? []), finding]); diff --git a/apps/web/src/features/ats-checker/run-ats-check.ts b/apps/web/src/features/ats-checker/run-ats-check.ts index 08e352f6d..1da2a84da 100644 --- a/apps/web/src/features/ats-checker/run-ats-check.ts +++ b/apps/web/src/features/ats-checker/run-ats-check.ts @@ -21,12 +21,15 @@ export type RunAtsCheckOptions = { * who never uploads a file never downloads either. */ export async function runAtsCheck(file: File, options: RunAtsCheckOptions = {}): Promise { + const engine = import("@reactive-resume/resume/ats-pdf"); + // Awaited below; this only keeps a failed chunk load from surfacing as unhandled when extraction throws first. + engine.catch(() => {}); const raw = await extractPdf(file, { ...(options.onProgress ? { onProgress: options.onProgress } : {}), ...(options.signal ? { signal: options.signal } : {}), }); - const { analyzePdfResume, buildExtractedDocument } = await import("@reactive-resume/resume/ats-pdf"); + const { analyzePdfResume, buildExtractedDocument } = await engine; const report = analyzePdfResume(raw, { ...(options.jobDescription?.trim() ? { jobDescription: options.jobDescription } : {}), diff --git a/apps/web/src/features/command-palette/pages/resumes.test.tsx b/apps/web/src/features/command-palette/pages/resumes.test.tsx index a4585ccee..980e5b3a6 100644 --- a/apps/web/src/features/command-palette/pages/resumes.test.tsx +++ b/apps/web/src/features/command-palette/pages/resumes.test.tsx @@ -173,7 +173,6 @@ describe("ResumesCommandGroup", () => { renderGroup(); expect(mocks.resumeQueryOptions).toHaveBeenCalledWith({ - enabled: true, input: { sort: "lastUpdatedAt", tags: [] }, }); expect(screen.getByText("Evil Apricot Pike")).toBeInTheDocument(); diff --git a/apps/web/src/features/command-palette/pages/resumes.tsx b/apps/web/src/features/command-palette/pages/resumes.tsx index 96cc1c816..94ab40430 100644 --- a/apps/web/src/features/command-palette/pages/resumes.tsx +++ b/apps/web/src/features/command-palette/pages/resumes.tsx @@ -16,6 +16,7 @@ import { BaseCommandGroup } from "./base"; type Application = RouterOutput["applications"]["list"][number]; type Thread = RouterOutput["agent"]["threads"]["list"][number]; type SearchPage = "resumes" | "applications" | "threads"; +type SearchPageProps = { page: SearchPage }; const isSearchPage = (page: string | undefined): page is SearchPage => page === "resumes" || page === "applications" || page === "threads"; @@ -26,81 +27,12 @@ const matchesSearch = (search: string, values: Array) }; export function ResumesCommandGroup() { - const navigate = useNavigate(); - const { openDialog } = useDialogStore(); const { session } = useRouteContext({ strict: false }); - const reset = useCommandPaletteStore((state) => state.reset); const peekPage = useCommandPaletteStore((state) => state.peekPage); const pushPage = useCommandPaletteStore((state) => state.pushPage); - const search = useCommandPaletteStore((state) => state.search); const commandPage = peekPage(); - const commandSearchPage = isSearchPage(commandPage) ? commandPage : undefined; - const searchPage = commandSearchPage; - - const { data: resumes, isLoading } = useQuery( - orpc.resume.list.queryOptions({ - enabled: !!session && searchPage === "resumes", - input: { sort: "lastUpdatedAt", tags: [] }, - }), - ); - const { data: applications, isLoading: isLoadingApplications } = useQuery({ - ...applicationsListQueryOptions(), - enabled: !!session && searchPage === "applications", - }); - const { data: threads, isLoading: isLoadingThreads } = useQuery( - orpc.agent.threads.list.queryOptions({ - enabled: !!session && searchPage === "threads", - }), - ); - - const filteredResumes = (resumes ?? []).filter((resume) => matchesSearch(search, [resume.name, resume.slug])); - const filteredApplications = (applications ?? []).filter((application) => - matchesSearch(search, [application.company, application.role]), - ); - const filteredThreads = (threads ?? []).filter((thread) => - matchesSearch(search, [thread.title, thread.resumeName, thread.coverLetterName]), - ); - - const onCreate = () => { - reset(); - openDialog("document.new", undefined); - }; - - const onNavigate = async (path: string) => { - await navigate({ to: path }); - reset(); - }; - - const onCreateApplication = async () => { - await navigate({ to: "/dashboard/applications", search: { create: true } }); - reset(); - }; - - const onOpenApplication = async (application: Application) => { - await navigate({ - to: "/dashboard/applications", - search: { applicationId: application.id }, - }); - reset(); - }; - - // A conversation opens its document with the assistant showing it. - const onOpenThread = async (thread: Thread) => { - if (thread.coverLetterId) - await navigate({ - to: "/builder/letter/$coverLetterId", - params: { coverLetterId: thread.coverLetterId }, - search: { assistant: thread.id }, - }); - else if (thread.workingResumeId) - await navigate({ - to: "/builder/$resumeId", - params: { resumeId: thread.workingResumeId }, - search: { assistant: thread.id }, - }); - reset(); - }; + const searchPage = isSearchPage(commandPage) ? commandPage : undefined; if (!session) return null; @@ -127,93 +59,168 @@ export function ResumesCommandGroup() { - {searchPage === "resumes" ? ( - Resumes}> - - - Create a new resume - - - {isLoading ? ( - - Loading resumes… - - ) : ( - filteredResumes.map((resume) => ( - onNavigate(`/builder/${resume.id}`)} - > - - {resume.name} - - - - Press Enter to open - - - - )) - )} - - ) : null} - - {searchPage === "applications" ? ( - Applications}> - - - New Application - - - {isLoadingApplications ? ( - - Loading applications… - - ) : ( - filteredApplications.map((application) => ( - onOpenApplication(application)} - > - - {application.company} - {application.role} - - )) - )} - - ) : null} - - {searchPage === "threads" ? ( - Assistant conversations}> - {isLoadingThreads ? ( - - Loading conversations… - - ) : ( - filteredThreads.map((thread) => { - const documentName = thread.coverLetterName ?? thread.resumeName ?? ""; - - return ( - onOpenThread(thread)} - > - - {thread.title} - {documentName} - - ); - }) - )} - - ) : null} + {searchPage === "resumes" ? : null} + {searchPage === "applications" ? : null} + {searchPage === "threads" ? : null} ); } + +function ResumesPage({ page }: SearchPageProps) { + const navigate = useNavigate(); + const { openDialog } = useDialogStore(); + const reset = useCommandPaletteStore((state) => state.reset); + const search = useCommandPaletteStore((state) => state.search); + const { data: resumes, isLoading } = useQuery( + orpc.resume.list.queryOptions({ input: { sort: "lastUpdatedAt", tags: [] } }), + ); + const filteredResumes = (resumes ?? []).filter((resume) => matchesSearch(search, [resume.name, resume.slug])); + + const onCreate = () => { + reset(); + openDialog("document.new", undefined); + }; + + const onNavigate = async (path: string) => { + await navigate({ to: path }); + reset(); + }; + + return ( + Resumes}> + + + Create a new resume + + + {isLoading ? ( + + Loading resumes… + + ) : ( + filteredResumes.map((resume) => ( + onNavigate(`/builder/${resume.id}`)} + > + + {resume.name} + + + + Press Enter to open + + + + )) + )} + + ); +} + +function ApplicationsPage({ page }: SearchPageProps) { + const navigate = useNavigate(); + const reset = useCommandPaletteStore((state) => state.reset); + const search = useCommandPaletteStore((state) => state.search); + const { data: applications, isLoading } = useQuery(applicationsListQueryOptions()); + const filteredApplications = (applications ?? []).filter((application) => + matchesSearch(search, [application.company, application.role]), + ); + + const onCreateApplication = async () => { + await navigate({ to: "/dashboard/applications", search: { create: true } }); + reset(); + }; + + const onOpenApplication = async (application: Application) => { + await navigate({ + to: "/dashboard/applications", + search: { applicationId: application.id }, + }); + reset(); + }; + + return ( + Applications}> + + + New Application + + + {isLoading ? ( + + Loading applications… + + ) : ( + filteredApplications.map((application) => ( + onOpenApplication(application)} + > + + {application.company} + {application.role} + + )) + )} + + ); +} + +function ThreadsPage({ page }: SearchPageProps) { + const navigate = useNavigate(); + const reset = useCommandPaletteStore((state) => state.reset); + const search = useCommandPaletteStore((state) => state.search); + const { data: threads, isLoading } = useQuery(orpc.agent.threads.list.queryOptions()); + const filteredThreads = (threads ?? []).filter((thread) => + matchesSearch(search, [thread.title, thread.resumeName, thread.coverLetterName]), + ); + + // A conversation opens its document with the assistant showing it. + const onOpenThread = async (thread: Thread) => { + if (thread.coverLetterId) + await navigate({ + to: "/builder/letter/$coverLetterId", + params: { coverLetterId: thread.coverLetterId }, + search: { assistant: thread.id }, + }); + else if (thread.workingResumeId) + await navigate({ + to: "/builder/$resumeId", + params: { resumeId: thread.workingResumeId }, + search: { assistant: thread.id }, + }); + reset(); + }; + + return ( + Assistant conversations}> + {isLoading ? ( + + Loading conversations… + + ) : ( + filteredThreads.map((thread) => { + const documentName = thread.coverLetterName ?? thread.resumeName ?? ""; + + return ( + onOpenThread(thread)} + > + + {thread.title} + {documentName} + + ); + }) + )} + + ); +} diff --git a/apps/web/src/features/homepage/ats-playground.tsx b/apps/web/src/features/homepage/ats-playground.tsx index 4b5d504c5..6aa380c8a 100644 --- a/apps/web/src/features/homepage/ats-playground.tsx +++ b/apps/web/src/features/homepage/ats-playground.tsx @@ -6,6 +6,21 @@ import { useEffect, useId, useRef, useState } from "react"; const focusRing = "focus-visible:outline-2 focus-visible:outline-(--home-accent) focus-visible:outline-offset-4"; const message = "max-w-[300px] text-[14px] leading-[1.8]"; +/** The extracted text of the chosen file, or of the bundled sample; null when the read was cancelled midway. */ +async function readPdf(selected: File | null, signal: AbortSignal) { + let source = selected; + if (!source) { + const response = await fetch("/templates/pdf/rhyhorn.pdf", { signal }); + if (!response.ok) throw new Error("Sample PDF unavailable"); + source = new File([await response.blob()], "rhyhorn.pdf", { type: "application/pdf" }); + } + if (signal.aborted) return null; + const { runAtsCheck } = await import("@/features/ats-checker/run-ats-check"); + if (signal.aborted) return null; + const result = await runAtsCheck(source, { signal }); + return signal.aborted ? null : result.fullText; +} + export default function AtsPlayground() { const inputId = useId(); const controllerRef = useRef(null); @@ -38,18 +53,9 @@ export default function AtsPlayground() { } setPhase("reading"); try { - let source = selected; - if (!source) { - const response = await fetch("/templates/pdf/rhyhorn.pdf", { signal: controller.signal }); - if (!response.ok) throw new Error("Sample PDF unavailable"); - source = new File([await response.blob()], "rhyhorn.pdf", { type: "application/pdf" }); - } - if (controller.signal.aborted) return; - const { runAtsCheck } = await import("@/features/ats-checker/run-ats-check"); - if (controller.signal.aborted) return; - const result = await runAtsCheck(source, { signal: controller.signal }); - if (controller.signal.aborted) return; - setText(result.fullText); + const fullText = await readPdf(selected, controller.signal); + if (fullText === null) return; + setText(fullText); setPhase("done"); } catch (caught) { if (controller.signal.aborted) return; diff --git a/apps/web/src/features/homepage/export-playground.tsx b/apps/web/src/features/homepage/export-playground.tsx index 7c7c578f3..ebb120629 100644 --- a/apps/web/src/features/homepage/export-playground.tsx +++ b/apps/web/src/features/homepage/export-playground.tsx @@ -164,14 +164,14 @@ export default function ExportPlayground({ name, accent, typeface, template }: E ]); const download = async () => { + const action = actions.get(format); setBusy(true); try { - await actions.get(format)?.(); + if (action) await action(); } catch { toast.add({ type: "error", description: t`Could not prepare the sample. Please try again.` }); - } finally { - setBusy(false); } + setBusy(false); }; return ( diff --git a/apps/web/src/features/homepage/feature-explorer.tsx b/apps/web/src/features/homepage/feature-explorer.tsx index deaf74bce..0d1a407e3 100644 --- a/apps/web/src/features/homepage/feature-explorer.tsx +++ b/apps/web/src/features/homepage/feature-explorer.tsx @@ -41,19 +41,37 @@ export function FeatureExplorer() { const ref = useRef(null); const visible = useInView(ref); const reducedMotion = useReducedMotion(); - const revisedText = t`Designed a shared component library that six product teams use to build consistent interfaces.`; - const sharingOptions = [ - { id: "public", label: t`Public` }, - { id: "password", label: t`Password` }, - { id: "private", label: t`Private` }, - ]; + // State lives here so a demo keeps its place when you switch away and back. const features = [ - { id: "writing", label: t`A hand with the writing`, hint: t`AI, when you want it`, icon: PencilSimpleIcon }, - { id: "sharing", label: t`A link worth sharing`, hint: t`Choose who can see it`, icon: GlobeIcon }, - { id: "tracking", label: t`Know where things stand`, hint: t`Your applications, together`, icon: KanbanIcon }, - { id: "tools", label: t`Fits into your workflow`, hint: t`API and MCP access`, icon: CodeIcon }, + { + id: "writing", + label: t`A hand with the writing`, + hint: t`AI, when you want it`, + icon: PencilSimpleIcon, + demo: , + }, + { + id: "sharing", + label: t`A link worth sharing`, + hint: t`Choose who can see it`, + icon: GlobeIcon, + demo: , + }, + { + id: "tracking", + label: t`Know where things stand`, + hint: t`Your applications, together`, + icon: KanbanIcon, + demo: , + }, + { + id: "tools", + label: t`Fits into your workflow`, + hint: t`API and MCP access`, + icon: CodeIcon, + demo: , + }, ]; - const stages = [t`Applied`, t`Interview`, t`Offer`]; return (
- {feature === "writing" && ( -
-
- - Example edit - - Alex Morgan -
-
- - Experience - -

- Product designer -

-

- {accepted ? ( - <> - - {revisedText} - - - - ) : ( - t`Responsible for working on the design system and helping other teams with their designs.` - )} -

-
-
-
-
-
-

- - This is a sample edit. In the app, connect your own AI provider. Turn on Review edits to approve changes - before they’re applied. - -

-
- )} - {feature === "sharing" && ( -
-
- - Sharing preview - -
-
- {sharingOptions.map(({ id, label }) => ( - - ))} -
-
-
-
-
- {sharingOptions.map(({ id }) => ( -
- {id === "public" ? ( - <> -
- AM -
-

Alex Morgan

-

- Product designer -

-
- - - -
- - ) : ( - <> -
- ))} -
-
-

- - Try the settings to see what visitors can access. When you create your resume, you decide how to share - it. - -

-
- )} - {feature === "tracking" && ( -
-
- - Example application - - -
-
-
- {stages.map((label, index) => ( -
- - {label} - {stage === index ? 1 : 0} -
- ))} -
-
- {stages.map((label) => ( - -
-
- {stages[stage]} - -
-

- - Keep the company, role, resume, and follow-up date together. Switch between a board, table, and insights - in the app. - -

-
- )} - {feature === "tools" && ( -
-
- - Your tools, connected - - {!reducedMotion && ( - - )} -
-
-
-
-
- - MCP - -