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 (
-
- );
-}
-
-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 (
-
-
-
- {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;
- }) => (
-