From cc28f785377670754f2da474dabae9b10277d1f1 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 00:41:54 +0200 Subject: [PATCH] feat(web): the shared resume page, reflowed on phones Desktop and tablets get a 64px bar with the owner's name, headline and city, Copy link and Download PDF, the page on the sunken canvas, and the footer credit. Phones get the resume as readable text in the template's colour and body font, in the order the PDF prints it (read from the semantic tree), with contact details as tap targets and Download and Share pinned. The owner's rich text is parsed into an allowlist of formatting elements and plain links, never injected. With downloads off, Download is hidden and printing shows a note instead of the page. Off, unknown and trashed links all read "This resume isn't shared right now." with nothing about the owner. --- .../resume/public/public-resume.test.tsx | 41 ++-- .../features/resume/public/public-resume.tsx | 182 ++++++++++++------ .../src/features/resume/public/reflow.test.ts | 31 +++ apps/web/src/features/resume/public/reflow.ts | 40 ++++ .../features/resume/public/resume-reflow.tsx | 162 ++++++++++++++++ .../features/resume/public/rich-text.test.tsx | 31 +++ .../src/features/resume/public/rich-text.tsx | 67 +++++++ apps/web/src/routes/$username/$slug.tsx | 4 + 8 files changed, 483 insertions(+), 75 deletions(-) create mode 100644 apps/web/src/features/resume/public/reflow.test.ts create mode 100644 apps/web/src/features/resume/public/reflow.ts create mode 100644 apps/web/src/features/resume/public/resume-reflow.tsx create mode 100644 apps/web/src/features/resume/public/rich-text.test.tsx create mode 100644 apps/web/src/features/resume/public/rich-text.tsx diff --git a/apps/web/src/features/resume/public/public-resume.test.tsx b/apps/web/src/features/resume/public/public-resume.test.tsx index b6c48e53b..e984710ad 100644 --- a/apps/web/src/features/resume/public/public-resume.test.tsx +++ b/apps/web/src/features/resume/public/public-resume.test.tsx @@ -37,6 +37,9 @@ vi.mock("@tanstack/react-router", () => ({ }), })); vi.mock("./pdf-viewer", () => ({ PdfViewer: publicResumeMock.PdfViewer })); +vi.mock("./resume-reflow", () => ({ ResumeReflow: () =>
})); +const breakpoint = vi.hoisted(() => ({ value: "desktop" })); +vi.mock("@reactive-resume/ui/hooks/use-breakpoint", () => ({ useBreakpoint: () => breakpoint.value })); vi.mock("@/libs/orpc/client", () => ({ orpc: { resume: { getBySlug: { queryOptions: () => ({ query: "resume" }) } } }, })); @@ -49,6 +52,7 @@ const { PublicResumeRoute } = await import("./public-resume"); beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} })); beforeEach(() => { + breakpoint.value = "desktop"; publicResumeMock.flags.disableSignups = false; publicResumeMock.resume = { data: sampleResumeData, name: "Sample Resume", slug: "sample" }; publicResumeMock.PdfViewer.mockClear(); @@ -71,39 +75,44 @@ const renderPublicResumeRoute = () => ); describe("PublicResumeRoute", () => { - it("shows the create-resume link when registration is enabled", () => { + it("links the footer credit home when registration is enabled", () => { renderPublicResumeRoute(); - expect(screen.getByRole("link", { name: /Build your own resume/ })).toHaveAttribute("href", "/"); + expect(screen.getByRole("link", { name: /Made with Reactive Resume/ })).toHaveAttribute("href", "/"); }); - it("hides the create-resume link when registration is disabled", () => { + it("keeps the credit as plain text when registration is disabled", () => { publicResumeMock.flags.disableSignups = true; renderPublicResumeRoute(); - expect(screen.queryByRole("link", { name: /Build your own resume/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /Made with Reactive Resume/ })).not.toBeInTheDocument(); + expect(screen.getByText(/Made with Reactive Resume/)).toBeInTheDocument(); expect(screen.getByTestId("pdf-viewer")).toBeInTheDocument(); }); - it("shows both working download controls by default", () => { + it("leads with the owner's name and downloads from the bar", () => { renderPublicResumeRoute(); - const buttons = screen.getAllByRole("button", { name: "Download PDF" }); - expect(buttons).toHaveLength(2); - for (const button of buttons) fireEvent.click(button); - expect(publicResumeMock.onDownloadPDF).toHaveBeenCalledTimes(2); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(sampleResumeData.basics.name); + expect(screen.getByRole("button", { name: /Copy link/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Download PDF" })); + expect(publicResumeMock.onDownloadPDF).toHaveBeenCalledTimes(1); }); - it("hides both download controls while keeping the public PDF visible", () => { + it("hides Download when downloads are off, keeps the page, and blocks printing with a note", () => { publicResumeMock.resume = { data: sampleResumeData, name: "Sample", slug: "sample", showDownloadButtons: false }; renderPublicResumeRoute(); expect(screen.queryByRole("button", { name: "Download PDF" })).not.toBeInTheDocument(); expect(screen.getByTestId("pdf-viewer")).toBeVisible(); + expect(screen.getByText("Printing is turned off for this resume.")).toHaveClass("print:block"); }); - it("shows both controls when the owner enables downloads again", () => { - publicResumeMock.resume = { data: sampleResumeData, name: "Sample", slug: "sample", showDownloadButtons: true }; + it("reflows on phones, with Download and Share pinned", () => { + breakpoint.value = "mobile"; renderPublicResumeRoute(); - expect(screen.getAllByRole("button", { name: "Download PDF" })).toHaveLength(2); + expect(screen.getByTestId("reflow")).toBeInTheDocument(); + expect(screen.queryByTestId("pdf-viewer")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Download PDF" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Share" })).toBeInTheDocument(); }); it("passes exposed source data directly to the browser viewer and export fallback", () => { @@ -125,9 +134,7 @@ describe("PublicResumeRoute", () => { renderPublicResumeRoute(); const viewerFrame = screen.getByTestId("pdf-viewer").parentElement; - const page = viewerFrame?.parentElement; - expect(page).not.toHaveClass("min-h-svh", "h-svh", "max-h-svh", "overflow-hidden"); - expect(viewerFrame).not.toHaveClass("min-h-0", "flex-1", "overflow-hidden"); + expect(viewerFrame).not.toHaveClass("min-h-0", "overflow-hidden", "h-svh", "max-h-svh"); }); }); @@ -145,7 +152,7 @@ describe("PublicResumePage at root", () => { /> , ); - expect(screen.getByRole("link", { name: /Build your own resume/ })).toHaveAttribute("href", "/dashboard"); + expect(screen.getByRole("link", { name: /Made with Reactive Resume/ })).toHaveAttribute("href", "/dashboard"); expect(screen.getByRole("main")).toHaveAttribute("id", "main-content"); expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(sampleResumeData.basics.name); expect(publicResumeMock.useResumeExport).toHaveBeenCalledWith(publicResumeMock.resume, { diff --git a/apps/web/src/features/resume/public/public-resume.tsx b/apps/web/src/features/resume/public/public-resume.tsx index 1461b6c4a..7fb410124 100644 --- a/apps/web/src/features/resume/public/public-resume.tsx +++ b/apps/web/src/features/resume/public/public-resume.tsx @@ -1,16 +1,19 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { CircleNotchIcon, DownloadSimpleIcon } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { getRouteApi } from "@tanstack/react-router"; -import { useMemo } from "react"; -import { BrandIcon } from "@reactive-resume/ui/components/brand-icon"; +import { useMemo, useState } from "react"; +import { useCopyToClipboard } from "usehooks-ts"; import { Button } from "@reactive-resume/ui/components/button"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { toast } from "@reactive-resume/ui/components/toast"; +import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint"; import { LoadingScreen } from "@/components/layout/loading-screen"; import { useResumeExport } from "@/features/resume/export/use-resume-export"; import { orpc } from "@/libs/orpc/client"; import { PdfViewer } from "./pdf-viewer"; +import { ResumeReflow } from "./resume-reflow"; const publicResumeRoute = getRouteApi("/$username/$slug"); @@ -30,74 +33,137 @@ type PublicResumePageProps = { isRoot?: boolean; }; +/** + * The shared resume, for recruiters: no app chrome, the owner's name leads, and Download PDF is the one action. On + * phones it reflows into readable text with Download pinned; with downloads off, Download is gone and printing shows + * a note instead of the page. + */ export function PublicResumePage({ resume, username, slug, flags, isRoot = false }: PublicResumePageProps) { const publicResume = useMemo(() => ({ username, slug }), [slug, username]); const { onDownloadPDF, isExporting } = useResumeExport(resume, { ...(resume ? { publicResumePdf: { publicResume } } : {}), }); + const phone = useBreakpoint() === "mobile"; + const [copied, setCopied] = useState(false); + const [, copy] = useCopyToClipboard(); if (!resume) return ; - const { basics, picture } = resume.data; - const showDownloadButtons = resume.showDownloadButtons !== false; + const { basics } = resume.data; + const downloads = resume.showDownloadButtons !== false; + const subtitle = [basics.headline, basics.location].filter(Boolean).join(" · "); + + const copyLink = async () => { + await copy(window.location.href); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const share = async () => { + if (typeof navigator.share === "function") { + try { + await navigator.share({ title: basics.name || resume.name, url: window.location.href }); + } catch { + // Closing the share sheet isn't an error. + } + return; + } + await copy(window.location.href); + toast.add({ description: t`Link copied` }); + }; + + const download = ( + + ); + + const credit = ( + + ); return ( - <> -
-
- {picture.url && !picture.hidden && ( - {basics.name} - )} -
- {basics.name &&

{basics.name}

} - {basics.headline &&

{basics.headline}

} -
- {showDownloadButtons && ( - - )} -
+
+ {/* Printing is part of downloading: when the owner turns downloads off, print shows this note. */} + {!downloads && ( +

+ Printing is turned off for this resume. +

+ )} -
- -
- - {!flags.disableSignups && ( - +
+ {phone ? ( + <> +
+

{basics.name || resume.name}

+ + {credit} +
+
+ {downloads && download} + +
+ + ) : ( + <> +
+
+

{basics.name || resume.name}

+ {subtitle &&

{subtitle}

} +
+ + {downloads && download} +
+
+
+ +
+
+ {credit} + )}
+
+ ); +} - {showDownloadButtons && ( - - )} - +/** Off, unknown or trashed links read the same, with nothing about the owner. */ +export function SharedResumeUnavailable() { + return ( +
+
+

+ This resume isn't shared right now. +

+

+ If someone sent you this link, ask them for a new one. +

+
+
); } diff --git a/apps/web/src/features/resume/public/reflow.test.ts b/apps/web/src/features/resume/public/reflow.test.ts new file mode 100644 index 000000000..1c6fe34df --- /dev/null +++ b/apps/web/src/features/resume/public/reflow.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; +import { reflowOrder } from "./reflow"; + +const clone = () => structuredClone(sampleResumeData); + +describe("reflowOrder", () => { + it("follows the print order: each page's main column, then its sidebar", () => { + const data = clone(); + const order = reflowOrder(data).map((section) => section.sectionId); + const pages = data.metadata.layout.pages; + const printed = pages.flatMap((page) => [...page.main, ...(page.fullWidth ? [] : page.sidebar)]); + + // Every reflowed section is printed, in the same relative order. + expect(order.length).toBeGreaterThan(0); + expect(order).toEqual(printed.filter((id) => order.includes(id))); + }); + + it("leaves out hidden sections and hidden entries", () => { + const data = clone(); + data.sections.skills.hidden = true; + const [first, second] = data.sections.projects.items; + if (first) first.hidden = true; + + const order = reflowOrder(data); + expect(order.map((section) => section.sectionId)).not.toContain("skills"); + const projects = order.find((section) => section.sectionId === "projects")?.itemIds ?? []; + expect(projects).not.toContain(first?.id); + expect(projects).toContain(second?.id); + }); +}); diff --git a/apps/web/src/features/resume/public/reflow.ts b/apps/web/src/features/resume/public/reflow.ts new file mode 100644 index 000000000..847ac6854 --- /dev/null +++ b/apps/web/src/features/resume/public/reflow.ts @@ -0,0 +1,40 @@ +import type { SemanticNode } from "@reactive-resume/resume/stylesheet/types"; +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import { buildSemanticTree, shouldShowResumeHeader } from "@reactive-resume/pdf/semantic-tree"; + +/** One section as the phone reflow shows it: its id and the ids of the entries the page prints. */ +export type ReflowSection = { sectionId: string; itemIds: string[] }; + +function collect(node: SemanticNode, into: ReflowSection[]) { + if (node.kind === "section" && node.id) { + const itemIds: string[] = []; + const findItems = (child: SemanticNode) => { + if (child.kind === "item" && child.id) itemIds.push(child.id); + else for (const grandchild of child.children) findItems(grandchild); + }; + for (const child of node.children) findItems(child); + into.push({ sectionId: node.id, itemIds }); + return; + } + for (const child of node.children) collect(child, into); +} + +/** + * The sections and entries in the order the PDF prints them, page by page, with hidden and empty ones left out. The + * semantic tree already applies the template's region order and the shared filtering, so the reflow reads it rather + * than repeating those rules. + */ +export function reflowOrder(data: ResumeData): ReflowSection[] { + const sections: ReflowSection[] = []; + data.metadata.layout.pages.forEach((page, index) => { + const tree = buildSemanticTree({ + data, + template: data.metadata.template, + page, + pageNumber: index + 1, + showHeader: shouldShowResumeHeader(data, index), + }); + collect(tree, sections); + }); + return sections; +} diff --git a/apps/web/src/features/resume/public/resume-reflow.tsx b/apps/web/src/features/resume/public/resume-reflow.tsx new file mode 100644 index 000000000..38af62d16 --- /dev/null +++ b/apps/web/src/features/resume/public/resume-reflow.tsx @@ -0,0 +1,162 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { IconName } from "@reactive-resume/ui/components/icon"; +import { useMemo } from "react"; +import { getResumeSectionTitle } from "@reactive-resume/pdf/section-title"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { cn } from "@reactive-resume/utils/style"; +import { reflowOrder } from "./reflow"; +import { RichText } from "./rich-text"; + +type Entry = Record & { id: string }; + +// Lists keep their markers and paragraphs their spacing, which the app's reset removes. +const RICH = "grid gap-1 [&_ol]:list-decimal [&_ol]:ps-5 [&_ul]:list-disc [&_ul]:ps-5 [&_a]:underline"; + +// What each kind of entry leads with, what follows it, and the date or place set beside it. +const SHAPES: Record = { + experience: { title: ["position", "company"], subtitle: ["company"], meta: ["period", "location"] }, + education: { title: ["school"], subtitle: ["degree", "area", "grade"], meta: ["period", "location"] }, + projects: { title: ["name"], subtitle: [], meta: ["period"] }, + skills: { title: ["name"], subtitle: ["proficiency"], meta: [] }, + languages: { title: ["language"], subtitle: ["fluency"], meta: [] }, + interests: { title: ["name"], subtitle: [], meta: [] }, + awards: { title: ["title"], subtitle: ["awarder"], meta: ["date"] }, + certifications: { title: ["title"], subtitle: ["issuer"], meta: ["date"] }, + publications: { title: ["title"], subtitle: ["publisher"], meta: ["date"] }, + volunteer: { title: ["organization"], subtitle: [], meta: ["period", "location"] }, + references: { title: ["name"], subtitle: ["position"], meta: [] }, + profiles: { title: ["network"], subtitle: ["username"], meta: [] }, + summary: { title: [], subtitle: [], meta: [] }, + "cover-letter": { title: [], subtitle: [], meta: [] }, +}; + +const text = (entry: Entry, field: string) => (typeof entry[field] === "string" ? (entry[field] as string).trim() : ""); + +function sectionOf(data: ResumeData, sectionId: string): { type: string; items: Entry[] } | null { + if (sectionId === "summary") return { type: "summary", items: [{ id: "summary", content: data.summary.content }] }; + if (sectionId in data.sections) { + const section = data.sections[sectionId as keyof ResumeData["sections"]]; + return { type: sectionId, items: section.items as unknown as Entry[] }; + } + const custom = data.customSections.find((section) => section.id === sectionId); + return custom ? { type: custom.type, items: custom.items as unknown as Entry[] } : null; +} + +function EntryView({ type, entry }: { type: string; entry: Entry }) { + const shape = SHAPES[type] ?? SHAPES.projects; + const title = shape?.title.map((field) => text(entry, field)).find(Boolean) ?? ""; + const subtitle = [ + ...(type === "experience" && text(entry, "position") ? ["company"] : []), + ...(shape?.subtitle.filter((field) => field !== "company") ?? []), + ] + .map((field) => text(entry, field)) + .filter((value) => value && value !== title); + const meta = shape?.meta.map((field) => text(entry, field)).filter(Boolean) ?? []; + const website = entry.website as { url?: string; label?: string } | undefined; + const url = typeof entry.url === "string" ? entry.url : website?.url; + const keywords = Array.isArray(entry.keywords) ? (entry.keywords as string[]).filter(Boolean) : []; + const roles = Array.isArray(entry.roles) ? (entry.roles as Entry[]) : []; + const html = text(entry, "content") || text(entry, "description"); + + return ( +
+ {title &&

{title}

} + {(subtitle.length > 0 || meta.length > 0) && ( +

{[...subtitle, ...meta].join(" · ")}

+ )} + {url && /^https?:/i.test(url) && ( + + {website?.label || url.replace(/^https?:\/\//, "")} + + )} + {keywords.length > 0 &&

{keywords.join(", ")}

} + {html && } + {roles.map((role) => ( +
+

{text(role, "position")}

+ {text(role, "period") &&

{text(role, "period")}

} + {text(role, "description") && } +
+ ))} +
+ ); +} + +type ContactPill = { icon: IconName; label: string; href: string }; + +function contactPills(basics: ResumeData["basics"]): ContactPill[] { + const pills: ContactPill[] = []; + if (basics.email) pills.push({ icon: "mail", label: basics.email, href: `mailto:${basics.email}` }); + if (basics.phone) pills.push({ icon: "call", label: basics.phone, href: `tel:${basics.phone.replace(/\s+/g, "")}` }); + if (basics.website.url) + pills.push({ + icon: "language", + label: basics.website.label || basics.website.url.replace(/^https?:\/\//, ""), + href: basics.website.url, + }); + for (const field of basics.customFields) + if (field.text && /^(https?:|mailto:|tel:)/i.test(field.link)) + pills.push({ icon: "link", label: field.text, href: field.link }); + return pills; +} + +type ResumeReflowProps = { data: ResumeData }; + +/** + * Phones: the resume as readable text at 15px, in the order the PDF prints it and in the template's colour and body + * font. Contact details are tap targets. The PDF stays the exact page. + */ +export function ResumeReflow({ data }: ResumeReflowProps) { + const order = useMemo(() => reflowOrder(data), [data]); + const { basics } = data; + const accent = data.metadata.design.colors.primary; + const font = data.metadata.typography.body.fontFamily; + + return ( +
+
+

{basics.name}

+ {(basics.headline || basics.location) && ( +

{[basics.headline, basics.location].filter(Boolean).join(" · ")}

+ )} + +
+ + {order.map(({ sectionId, itemIds }) => { + const section = sectionOf(data, sectionId); + if (!section) return null; + const entries = itemIds.length + ? itemIds.map((id) => section.items.find((item) => item.id === id)).filter((item): item is Entry => !!item) + : section.items.filter((item) => !item.hidden); + if (entries.length === 0) return null; + return ( +
+

+ {getResumeSectionTitle(data, sectionId)} +

+ {entries.map((entry) => ( + + ))} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/features/resume/public/rich-text.test.tsx b/apps/web/src/features/resume/public/rich-text.test.tsx new file mode 100644 index 000000000..46ba87607 --- /dev/null +++ b/apps/web/src/features/resume/public/rich-text.test.tsx @@ -0,0 +1,31 @@ +// @vitest-environment happy-dom + +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { RichText } from "./rich-text"; + +describe("RichText", () => { + it("keeps formatting and plain links", () => { + const { container } = render( +
  • Led design at Lumen

  • '} + />, + ); + expect(container.querySelector("li strong")?.textContent).toBe("design"); + expect(container.querySelector("a")?.getAttribute("href")).toBe("https://lumen.health"); + expect(container.querySelector("a")?.getAttribute("rel")).toContain("noopener"); + }); + + it("never runs the owner's markup", () => { + const { container } = render( + Hix

    ' + } + />, + ); + expect(container.querySelector("img, script, [onclick]")).toBeNull(); + expect(container.querySelector("a")).toBeNull(); + expect(container.textContent).toBe("Hix"); + }); +}); diff --git a/apps/web/src/features/resume/public/rich-text.tsx b/apps/web/src/features/resume/public/rich-text.tsx new file mode 100644 index 000000000..1dcc557a5 --- /dev/null +++ b/apps/web/src/features/resume/public/rich-text.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; +import { createElement } from "react"; + +const TAGS: Record = { + p: "p", + br: "br", + ul: "ul", + ol: "ol", + li: "li", + strong: "strong", + b: "strong", + em: "em", + i: "em", + u: "u", + s: "s", + strike: "s", + del: "s", + code: "code", + mark: "mark", + blockquote: "blockquote", + h1: "p", + h2: "p", + h3: "p", + h4: "p", + h5: "p", + h6: "p", +}; + +// Their contents are code or markup, not text anyone wrote for the page. +const DROPPED = new Set(["script", "style", "template", "noscript", "iframe", "object"]); + +const SAFE_HREF = /^(https?:|mailto:|tel:)/i; + +function toReact(node: Node, key: number): ReactNode { + if (node.nodeType === Node.TEXT_NODE) return node.textContent; + if (node.nodeType !== Node.ELEMENT_NODE) return null; + + const element = node as Element; + const tag = element.tagName.toLowerCase(); + if (DROPPED.has(tag)) return null; + const children = Array.from(element.childNodes).map(toReact); + + if (tag === "a") { + const href = element.getAttribute("href")?.trim() ?? ""; + if (!SAFE_HREF.test(href)) return createElement("span", { key }, ...children); + return createElement("a", { key, href, target: "_blank", rel: "noopener noreferrer nofollow" }, ...children); + } + + const allowed = TAGS[tag]; + // Anything else (spans, divs, tables, scripts…) keeps only its text. + if (!allowed) return children.length ? createElement("span", { key }, ...children) : null; + if (allowed === "br") return createElement("br", { key }); + if (tag.startsWith("h")) return createElement("p", { key }, createElement("strong", null, ...children)); + return createElement(allowed, { key }, ...children); +} + +type RichTextProps = { html: string; className?: string }; + +/** + * A resume's rich text on the public page. The owner's HTML is never injected: it's parsed, and only formatting tags + * and plain links become elements, so whatever else it holds stays inert text. + */ +export function RichText({ html, className }: RichTextProps) { + if (!html.trim()) return null; + const body = new DOMParser().parseFromString(html, "text/html").body; + return
    {Array.from(body.childNodes).map(toReact)}
    ; +} diff --git a/apps/web/src/routes/$username/$slug.tsx b/apps/web/src/routes/$username/$slug.tsx index 7005f17b3..c6af9ab68 100644 --- a/apps/web/src/routes/$username/$slug.tsx +++ b/apps/web/src/routes/$username/$slug.tsx @@ -11,6 +11,10 @@ type LoaderData = Omit & { data: Re export const Route = createFileRoute("/$username/$slug")({ ssr: "data-only", component: lazyRouteComponent(() => import("@/features/resume/public/public-resume"), "PublicResumeRoute"), + notFoundComponent: lazyRouteComponent( + () => import("@/features/resume/public/public-resume"), + "SharedResumeUnavailable", + ), loader: async ({ context, params }) => { const { username, slug } = params; const resume = await context.queryClient.ensureQueryData(