From 82fa32790099dfc5901f76cd8476a58751621231 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 28 Sep 2026 23:08:10 +0200 Subject: [PATCH] feat(web): letters on the editor shell, with drafting, History and both files to download --- .../application-ai-copilot.test.tsx | 17 +- .../components/application-ai-copilot.tsx | 8 +- .../components/detail/sent-documents.tsx | 20 +- .../features/applications/csv-export.test.ts | 1 + .../features/cover-letters/editor-dialog.tsx | 398 ----------- .../features/cover-letters/editor.test.tsx | 126 ---- .../web/src/features/cover-letters/editor.tsx | 147 ---- .../src/features/documents/document-card.tsx | 56 +- .../src/features/documents/documents-page.tsx | 7 - .../documents/new-document-dialog.tsx | 18 +- .../web/src/features/documents/trash-page.tsx | 8 +- apps/web/src/features/letters/compose.ts | 35 + apps/web/src/features/letters/export.ts | 25 + apps/web/src/features/letters/length.test.ts | 18 + apps/web/src/features/letters/length.ts | 19 + apps/web/src/features/letters/store.test.ts | 100 +++ apps/web/src/features/letters/store.ts | 163 +++++ .../src/features/letters/use-letter-mode.ts | 44 ++ .../web/src/features/resume/editor/chrome.tsx | 158 +++++ .../features/resume/editor/design/presets.ts | 2 +- .../resume/editor/write/entry-card.tsx | 12 +- .../resume/editor/write/rich-text-editor.tsx | 19 +- .../resume/editor/write/write-panel.test.tsx | 2 +- .../resume/preview/preview.browser.tsx | 16 +- .../resume/preview/preview.shared.tsx | 2 + .../features/resume/share/download-tab.tsx | 313 ++++++--- .../src/features/resume/share/history-tab.tsx | 164 +++-- apps/web/src/routeTree.gen.ts | 22 + .../$resumeId/-components/editor-bar.tsx | 63 +- .../$resumeId/-components/editor-shell.tsx | 18 +- .../$resumeId/-components/page-canvas.tsx | 77 +-- .../routes/builder/letter/$coverLetterId.tsx | 69 ++ .../letter/-components/design-panel.tsx | 161 +++++ .../builder/letter/-components/letter-bar.tsx | 313 +++++++++ .../letter/-components/letter-page.tsx | 212 ++++++ .../letter/-components/letter-shell.tsx | 270 ++++++++ .../letter/-components/share-sheet.tsx | 214 ++++++ .../letter/-components/write-panel.tsx | 648 ++++++++++++++++++ apps/web/src/routes/dashboard/index.tsx | 8 +- 39 files changed, 2899 insertions(+), 1074 deletions(-) delete mode 100644 apps/web/src/features/cover-letters/editor-dialog.tsx delete mode 100644 apps/web/src/features/cover-letters/editor.test.tsx delete mode 100644 apps/web/src/features/cover-letters/editor.tsx create mode 100644 apps/web/src/features/letters/compose.ts create mode 100644 apps/web/src/features/letters/export.ts create mode 100644 apps/web/src/features/letters/length.test.ts create mode 100644 apps/web/src/features/letters/length.ts create mode 100644 apps/web/src/features/letters/store.test.ts create mode 100644 apps/web/src/features/letters/store.ts create mode 100644 apps/web/src/features/letters/use-letter-mode.ts create mode 100644 apps/web/src/features/resume/editor/chrome.tsx create mode 100644 apps/web/src/routes/builder/letter/$coverLetterId.tsx create mode 100644 apps/web/src/routes/builder/letter/-components/design-panel.tsx create mode 100644 apps/web/src/routes/builder/letter/-components/letter-bar.tsx create mode 100644 apps/web/src/routes/builder/letter/-components/letter-page.tsx create mode 100644 apps/web/src/routes/builder/letter/-components/letter-shell.tsx create mode 100644 apps/web/src/routes/builder/letter/-components/share-sheet.tsx create mode 100644 apps/web/src/routes/builder/letter/-components/write-panel.tsx diff --git a/apps/web/src/features/applications/components/application-ai-copilot.test.tsx b/apps/web/src/features/applications/components/application-ai-copilot.test.tsx index 509225496..cbbc2785c 100644 --- a/apps/web/src/features/applications/components/application-ai-copilot.test.tsx +++ b/apps/web/src/features/applications/components/application-ai-copilot.test.tsx @@ -8,8 +8,7 @@ import { i18n } from "@lingui/core"; import { I18nProvider } from "@lingui/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -const mocks = vi.hoisted(() => ({ draft: vi.fn(), other: vi.fn() })); -type MockEditorDialogProps = { letterId: string }; +const mocks = vi.hoisted(() => ({ draft: vi.fn(), other: vi.fn(), navigate: vi.fn() })); vi.mock("@/libs/orpc/client", () => ({ orpc: { applications: { @@ -22,9 +21,12 @@ vi.mock("@/libs/orpc/client", () => ({ coverLetters: { list: { key: () => ["cover-letters"] } }, }, })); -vi.mock("@/features/cover-letters/editor-dialog", () => ({ - CoverLetterEditorDialog: ({ letterId }: MockEditorDialogProps) =>
Saved letter {letterId}
, -})); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => mocks.navigate })); + +const opened = (coverLetterId: string) => + waitFor(() => + expect(mocks.navigate).toHaveBeenCalledWith({ to: "/builder/letter/$coverLetterId", params: { coverLetterId } }), + ); const { ApplicationAiCopilot } = await import("./application-ai-copilot"); const application: Application = { @@ -38,6 +40,7 @@ const application: Application = { closedReason: null, coverLetterId: null, sentResumeVersionId: null, + sentCoverLetterVersionId: null, sentCheckScore: null, requirements: [], resumeId: null, @@ -102,7 +105,7 @@ it.each(["cover-letter", "follow-up"] as const)( kind === "cover-letter" ? { text: "Letter", coverLetterId: "letter-one" } : { text: "Follow-up" }, ), ); - if (kind === "cover-letter") expect(await screen.findByRole("dialog")).toHaveTextContent("letter-one"); + if (kind === "cover-letter") await opened("letter-one"); else expect(await screen.findByText("Follow-up")).toBeVisible(); expect(coverLetter).toBeEnabled(); expect(followUp).toBeEnabled(); @@ -125,6 +128,6 @@ it("prevents duplicate saved letters after an earlier follow-up completed", asyn await userEvent.click(coverLetter); expect(mocks.draft).toHaveBeenCalledTimes(2); await act(async () => request.resolve({ text: "Saved letter", coverLetterId: "letter-two" })); - expect(await screen.findByRole("dialog")).toHaveTextContent("letter-two"); + await opened("letter-two"); expect(screen.queryByText("Earlier follow-up")).not.toBeInTheDocument(); }); diff --git a/apps/web/src/features/applications/components/application-ai-copilot.tsx b/apps/web/src/features/applications/components/application-ai-copilot.tsx index 72a45e943..1575eee30 100644 --- a/apps/web/src/features/applications/components/application-ai-copilot.tsx +++ b/apps/web/src/features/applications/components/application-ai-copilot.tsx @@ -12,10 +12,10 @@ import { SpinnerGapIcon, } from "@phosphor-icons/react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; import { useCallback, useState } from "react"; import { toast } from "@reactive-resume/ui/components/toast"; import { cn } from "@reactive-resume/utils/style"; -import { CoverLetterEditorDialog } from "@/features/cover-letters/editor-dialog"; import { orpc } from "@/libs/orpc/client"; import { applicationsListQueryKey } from "../queries"; @@ -103,7 +103,7 @@ type Props = { application: Application }; export function ApplicationAiCopilot({ application }: Props) { const queryClient = useQueryClient(); const [draft, setDraft] = useState<{ kind: string; text: string } | null>(null); - const [coverLetterId, setCoverLetterId] = useState(null); + const navigate = useNavigate(); const invalidate = () => { void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() }); @@ -132,8 +132,9 @@ export function ApplicationAiCopilot({ application }: Props) { onSuccess: (result, variables) => { if (result.coverLetterId) { setDraft(null); - setCoverLetterId(result.coverLetterId); void queryClient.invalidateQueries({ queryKey: orpc.coverLetters.list.key() }); + // The saved letter opens in the letter editor. + void navigate({ to: "/builder/letter/$coverLetterId", params: { coverLetterId: result.coverLetterId } }); } else { setDraft({ kind: variables.kind, text: result.text }); } @@ -284,7 +285,6 @@ export function ApplicationAiCopilot({ application }: Props) {

)} - {coverLetterId && setCoverLetterId(null)} />} ); } diff --git a/apps/web/src/features/applications/components/detail/sent-documents.tsx b/apps/web/src/features/applications/components/detail/sent-documents.tsx index 5a710f72d..1dd324ce9 100644 --- a/apps/web/src/features/applications/components/detail/sent-documents.tsx +++ b/apps/web/src/features/applications/components/detail/sent-documents.tsx @@ -15,8 +15,6 @@ import { PIPELINE } from "../../stages"; import { useInvalidateApplications } from "../../use-application-actions"; import { FileAttachmentField } from "../file-attachment-field"; -const escapeHtml = (text: string) => text.replace(/&/g, "&").replace(//g, ">"); - /** When the application was sent: its first stage at Applied or beyond. */ function sentOn(application: Application) { const applied = PIPELINE.indexOf("applied"); @@ -52,17 +50,16 @@ export function SentDocuments({ application, disabled }: SentDocumentsProps) { const letter = documents?.find((document) => document.type === "letter" && document.id === application.coverLetterId); const date = sentOn(application).toLocaleDateString(i18n.locale, { month: "short", day: "numeric" }); + // A structured letter: the server fills the recipient from the application and makes it the application's letter. const writeLetter = async () => { try { const created = await createLetter.mutateAsync({ name: t`Cover letter — ${application.company}`.slice(0, 100), - recipient: `

${escapeHtml(t`Hiring team, ${application.company}`)}

`, applicationId: application.id, ...(application.resumeId ? { resumeId: application.resumeId } : {}), }); - await update.mutateAsync({ id: application.id, coverLetterId: created.id }); - toast.add({ description: t`Letter created with the recipient filled in` }); - void navigate({ to: "/dashboard", search: { letter: created.id } }); + invalidate(application.id); + void navigate({ to: "/builder/letter/$coverLetterId", params: { coverLetterId: created.id } }); } catch (error) { toast.add({ type: "error", @@ -113,12 +110,17 @@ export function SentDocuments({ application, disabled }: SentDocumentsProps) {
{letter?.name ?? t`Cover letter`} - Cover letter + {application.sentCoverLetterVersionId ? ( + Version sent {date} + ) : ( + Linked · not sent yet + )}
Open diff --git a/apps/web/src/features/applications/csv-export.test.ts b/apps/web/src/features/applications/csv-export.test.ts index 9fa76d69b..ffeaf6ee5 100644 --- a/apps/web/src/features/applications/csv-export.test.ts +++ b/apps/web/src/features/applications/csv-export.test.ts @@ -11,6 +11,7 @@ const application: Application = { closedReason: null, coverLetterId: null, sentResumeVersionId: null, + sentCoverLetterVersionId: null, sentCheckScore: null, requirements: [], location: "Berlin", diff --git a/apps/web/src/features/cover-letters/editor-dialog.tsx b/apps/web/src/features/cover-letters/editor-dialog.tsx deleted file mode 100644 index 39de76a94..000000000 --- a/apps/web/src/features/cover-letters/editor-dialog.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data"; -import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ClientOnly, useBlocker } from "@tanstack/react-router"; -import { lazy, Suspense, useCallback, useId, useMemo, useRef, useState } from "react"; -import { createCoverLetterResumeData } from "@reactive-resume/resume/cover-letter"; -import { templateSchema } from "@reactive-resume/schema/templates"; -import { Button } from "@reactive-resume/ui/components/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@reactive-resume/ui/components/dialog"; -import { Label } from "@reactive-resume/ui/components/label"; -import { Separator } from "@reactive-resume/ui/components/separator"; -import { Spinner } from "@reactive-resume/ui/components/spinner"; -import { toast } from "@reactive-resume/ui/components/toast"; -import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file"; -import { Combobox } from "@/components/ui/combobox"; -import { templates } from "@/dialogs/resume/template/data"; -import { useConfirm } from "@/hooks/use-confirm"; -import { getReadableErrorMessage } from "@/libs/error-message"; -import { orpc } from "@/libs/orpc/client"; -import { CoverLetterEditor } from "./editor"; - -const PdfViewer = lazy(() => - import("@/features/resume/public/pdf-viewer").then((module) => ({ default: module.PdfViewer })), -); - -type CoverLetterEditorDialogProps = { - letterId: string; - onClose: () => void; - activeResumeId?: string; - resumeReady?: boolean; -}; - -export function CoverLetterEditorDialog({ - letterId, - onClose, - activeResumeId, - resumeReady = true, -}: CoverLetterEditorDialogProps) { - const queryClient = useQueryClient(); - const confirm = useConfirm(); - const [editState, setEditState] = useState({ dirty: false, pending: false }); - const [reloadVersion, setReloadVersion] = useState(0); - const [busy, setBusy] = useState(false); - const running = useRef(false); - const query = useQuery(orpc.coverLetters.getById.queryOptions({ input: { id: letterId } })); - const onDirtyChange = useCallback((dirty: boolean, pending: boolean) => setEditState({ dirty, pending }), []); - - const canClose = () => { - if (editState.pending || running.current) return false; - if (!editState.dirty) return true; - return confirm(t`Discard unsaved changes?`, { - description: t`Your saved cover letter will remain unchanged.`, - confirmText: t`Discard`, - cancelText: t`Keep editing`, - }); - }; - // Closing can navigate (Documents keeps the open letter in the URL); once decided, the blocker lets it through. - const closing = useRef(false); - const close = () => { - closing.current = true; - onClose(); - }; - const requestClose = async () => { - if (await canClose()) close(); - }; - useBlocker({ - shouldBlockFn: async () => !closing.current && !(await canClose()), - enableBeforeUnload: editState.dirty || editState.pending || busy, - }); - - const remember = (letter: CoverLetter) => { - queryClient.setQueryData(orpc.coverLetters.getById.queryKey({ input: { id: letter.id } }), letter); - void queryClient.invalidateQueries({ queryKey: orpc.documents.key() }); - }; - const run = async (action: () => Promise) => { - if (running.current) return; - running.current = true; - setBusy(true); - try { - await action(); - } catch (error) { - toast.add({ - type: "error", - description: getReadableErrorMessage(error, t`Could not complete this action. Please try again.`), - }); - } finally { - running.current = false; - setBusy(false); - } - }; - const reload = async () => { - if (!(await canClose())) return; - await run(async () => { - remember(await orpc.coverLetters.getById.call({ id: letterId })); - setReloadVersion((version) => version + 1); - }); - }; - - return ( - { - if (!open) void requestClose(); - }} - > - - - - Edit cover letter - - - Changes are shared between your library and the resume builder. - - - {query.isPending ? ( - - ) : query.error ? ( -
-

{getReadableErrorMessage(query.error, t`Could not load this cover letter.`)}

- - -
- ) : ( - <> - void requestClose()} - onDirtyChange={onDirtyChange} - onSave={async (changes) => { - const updated = await orpc.coverLetters.update.call({ id: letterId, ...changes }); - remember(updated); - return updated; - }} - actions={(letter, disabled) => ( - - )} - /> - - - )} -
-
- ); -} - -type CoverLetterActionsProps = { - letter: CoverLetter; - activeResumeId?: string; - resumeReady: boolean; - disabled: boolean; - run: (action: () => Promise) => Promise; - onUpdated: (letter: CoverLetter) => void; - onDeleted: () => void; -}; - -function CoverLetterActions({ - letter, - activeResumeId, - resumeReady, - disabled, - run, - onUpdated, - onDeleted, -}: CoverLetterActionsProps) { - const queryClient = useQueryClient(); - const confirm = useConfirm(); - const styleId = useId(); - const templateId = useId(); - const applicationId = useId(); - const [resumeId, setResumeId] = useState(letter.sourceResumeId); - const [selectedApplicationId, setSelectedApplicationId] = useState(letter.sourceApplicationId); - const [preview, setPreview] = useState(false); - const sourceReady = resumeId !== activeResumeId || resumeReady; - const resumes = useQuery(orpc.resume.list.queryOptions({ input: {} })); - const applications = useQuery(orpc.applications.list.queryOptions({ input: { includeArchived: false } })); - const data = useMemo(() => createCoverLetterResumeData(letter), [letter]); - const templateOptions = templateSchema.options.map((value) => ({ value, label: templates[value].name })); - const createPdf = async () => { - const { createResumePdfBlob } = await import("@/features/resume/export/pdf-document"); - return createResumePdfBlob(data, undefined, { includeCoverLetterHeader: true }); - }; - - return ( -
- -
-
- - - - - -
-
- - { - const template = templateSchema.safeParse(value); - if (!template.success) return; - void run(async () => { - onUpdated( - await orpc.coverLetters.update.call({ - id: letter.id, - expectedRevision: letter.revision, - template: template.data, - }), - ); - }); - }} - /> -
-
- -
- ({ value: resume.id, label: resume.name }))} - value={resumeId} - onValueChange={setResumeId} - placeholder={t`Choose a resume`} - /> - -
-

- Styling and sender details are copied. Future resume edits apply only when you refresh. -

- {!sourceReady && ( -

- Save resume changes before copying its content or styling. -

- )} -
-
- -
- ({ - value: application.id, - label: `${application.company} — ${application.role}`, - }))} - value={selectedApplicationId} - onValueChange={setSelectedApplicationId} - placeholder={t`Choose an application`} - /> - -
-
-
- {preview && ( - }> - }> - - - - )} -
- ); -} diff --git a/apps/web/src/features/cover-letters/editor.test.tsx b/apps/web/src/features/cover-letters/editor.test.tsx deleted file mode 100644 index a596d0589..000000000 --- a/apps/web/src/features/cover-letters/editor.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -// @vitest-environment happy-dom - -import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data"; -import type { ReactNode } from "react"; -import { cleanup, fireEvent, render as renderComponent, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; -import { ORPCError } from "@orpc/client"; -import { defaultResumeData } from "@reactive-resume/schema/resume/default"; -import { CoverLetterEditor } from "./editor"; - -beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} })); -afterEach(cleanup); -type WrapperProps = { children: ReactNode }; -function Wrapper({ children }: WrapperProps) { - return {children}; -} -const render = (ui: ReactNode) => renderComponent(ui, { wrapper: Wrapper }); - -vi.mock("@/components/input/rich-input", () => ({ - RichInput: ({ - value, - onChange, - "aria-label": label, - editable, - }: { - value: string; - onChange: (value: string) => void; - "aria-label": string; - editable?: boolean; - }) => ( -