From 585047da9e5bd64738bf61ea4734a5038d087b1c Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 18 Jul 2026 10:34:56 +0200 Subject: [PATCH] fix(career): public CV deep links work on direct load, refresh, and shared links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4.5 (priority 4). The app is a React Router SPA behind Next static export, which only generated `/` — so a hard load of any deep path (/cv/{slug}, /login, /career/builder/…) hit Next's client not-found before React Router could route it. Replace the single app/page.tsx with an optional catch-all app/[[...slug]] (server page + client shell so generateStaticParams stays server-only) that matches every path; nginx already serves index.html for unknown paths (try_files), so React Router now owns routing on direct load. Also: PublicCvPage shows a friendly 404 empty state and sets the document title. Verified live — /login and /cv/{slug} both resolve on direct navigation. Co-Authored-By: Claude Opus 4.8 --- job-tracker-ui/app/[[...slug]]/ClientShell.tsx | 13 +++++++++++++ job-tracker-ui/app/[[...slug]]/page.tsx | 15 +++++++++++++++ job-tracker-ui/app/page.tsx | 12 ------------ job-tracker-ui/src/views/PublicCvPage.tsx | 12 +++++++++--- 4 files changed, 37 insertions(+), 15 deletions(-) create mode 100644 job-tracker-ui/app/[[...slug]]/ClientShell.tsx create mode 100644 job-tracker-ui/app/[[...slug]]/page.tsx delete mode 100644 job-tracker-ui/app/page.tsx diff --git a/job-tracker-ui/app/[[...slug]]/ClientShell.tsx b/job-tracker-ui/app/[[...slug]]/ClientShell.tsx new file mode 100644 index 0000000..eb5e085 --- /dev/null +++ b/job-tracker-ui/app/[[...slug]]/ClientShell.tsx @@ -0,0 +1,13 @@ +"use client"; + +import dynamic from "next/dynamic"; + +// The whole app is a client-side React Router SPA whose providers read window/localStorage during +// their initial render -- ssr:false keeps Next's static prerender from ever executing any of it on +// the server. Kept in its own client module so the route's page.tsx can stay a server component +// (generateStaticParams is a server-only export and cannot live in a "use client" file). +const ClientApp = dynamic(() => import("../../src/ClientApp"), { ssr: false }); + +export default function ClientShell() { + return ; +} diff --git a/job-tracker-ui/app/[[...slug]]/page.tsx b/job-tracker-ui/app/[[...slug]]/page.tsx new file mode 100644 index 0000000..0352501 --- /dev/null +++ b/job-tracker-ui/app/[[...slug]]/page.tsx @@ -0,0 +1,15 @@ +import ClientShell from "./ClientShell"; + +// Optional catch-all so EVERY path (/, /cv/{slug}, /career/builder/…) resolves to this same shell on +// a hard load. Without it, `output: export` only generates `/`, and Next's client renders its +// not-found for any deep URL before React Router can route it — breaking refresh and shared links. +// generateStaticParams emits just index.html (the empty slug); nginx (try_files $uri /index.html) +// serves that shell for every unknown path, and this catch-all matches it client-side so React +// Router takes over. See docs/architecture/frontend.md (SPA routing). +export function generateStaticParams() { + return [{ slug: [] }]; +} + +export default function Page() { + return ; +} diff --git a/job-tracker-ui/app/page.tsx b/job-tracker-ui/app/page.tsx deleted file mode 100644 index b08cf30..0000000 --- a/job-tracker-ui/app/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; - -// The whole app is a client-side React Router SPA whose providers read window/localStorage -// during their initial render -- ssr:false keeps Next's static prerender from ever executing -// any of it on the server. -const ClientApp = dynamic(() => import("../src/ClientApp"), { ssr: false }); - -export default function Page() { - return ; -} diff --git a/job-tracker-ui/src/views/PublicCvPage.tsx b/job-tracker-ui/src/views/PublicCvPage.tsx index 26284a4..da80baa 100644 --- a/job-tracker-ui/src/views/PublicCvPage.tsx +++ b/job-tracker-ui/src/views/PublicCvPage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; -import { api, getApiErrorMessage } from "../api"; +import { api } from "../api"; // Anonymous read-only public CV at /cv/:slug. Renders the server-produced HTML in a sandboxed // iframe. noindex is enforced server-side (X-Robots-Tag) and reinforced with a meta tag here. @@ -18,8 +18,14 @@ export default function PublicCvPage() { let alive = true; api.get<{ html: string; name: string }>(`/public-cv/${slug}`) - .then((r) => { if (alive) setHtml(r.data.html); }) - .catch((err) => { if (alive) setError(getApiErrorMessage(err, "This CV is not available.")); }); + .then((r) => { if (alive) { setHtml(r.data.html); if (r.data.name) document.title = `${r.data.name} — CV`; } }) + .catch((err) => { + if (!alive) return; + // 404 = unknown slug or the owner made it private again. Anything else is a transient error. + setError(err?.response?.status === 404 + ? "This CV isn’t available. The link may be wrong, or the owner has made it private." + : "This CV can’t be loaded right now. Please try again in a moment."); + }); return () => { alive = false;